Często przywoływany link do programów Unity3D w szczegółach jest martwy. Ponieważ jest o tym mowa w komentarzach i odpowiedziach, zamieszczam tutaj treść artykułu. Ta treść pochodzi z tego lustra .
Szczegółowe informacje na temat programów Unity3D
Wiele procesów w grach odbywa się w trakcie wielu klatek. Masz `` gęste '' procesy, takie jak wyszukiwanie ścieżki, które ciężko pracują w każdej klatce, ale są dzielone na wiele klatek, aby nie wpływać zbytnio na liczbę klatek na sekundę. Masz „rzadkie” procesy, takie jak wyzwalacze rozgrywki, które nie robią nic w większości klatek, ale czasami są wzywani do wykonania krytycznej pracy. I masz różne procesy między nimi.
Ilekroć tworzysz proces, który będzie się odbywał w wielu klatkach - bez wielowątkowości - musisz znaleźć sposób na podzielenie pracy na fragmenty, które można uruchomić po jednej na klatkę. Dla każdego algorytmu z centralną pętlą jest to dość oczywiste: na przykład pathfinder A * może być tak skonstruowany, że utrzymuje swoje listy węzłów półtrwale, przetwarzając tylko kilka węzłów z listy otwartej w każdej ramce, zamiast próbować wykonać całą pracę za jednym zamachem. Trzeba trochę zbalansować, aby zarządzać opóźnieniami - w końcu, jeśli blokujesz liczbę klatek na sekundę na 60 lub 30 klatek na sekundę, twój proces zajmie tylko 60 lub 30 kroków na sekundę, co może spowodować, że proces po prostu zajmie ogólnie za długo. Zgrabny projekt może oferować najmniejszą możliwą jednostkę pracy na jednym poziomie - np przetwarzaj pojedynczy węzeł A * - i warstwę na wierzchu w sposób grupujący prace razem w większe porcje - np. kontynuuj przetwarzanie węzłów A * przez X milisekund. (Niektórzy nazywają to „podziałem czasu”, chociaż ja tego nie robię).
Jednak pozwolenie na podzielenie pracy w ten sposób oznacza, że musisz przenosić stan z jednej ramki do drugiej. Jeśli łamiesz algorytm iteracyjny, musisz zachować cały stan współdzielony przez iteracje, a także sposób śledzenia, która iteracja ma zostać wykonana jako następna. Zwykle nie jest tak źle - konstrukcja klasy „A * pathfinder” jest dość oczywista - ale są też inne przypadki, które są mniej przyjemne. Czasami będziesz musiał zmierzyć się z długimi obliczeniami, które wykonują różne rodzaje pracy od klatki do klatki; obiekt przechwytujący ich stan może skończyć się dużym bałaganem częściowo przydatnych „lokalnych”, przechowywanych do przekazywania danych z jednej klatki do drugiej. A jeśli masz do czynienia z rzadkim procesem, często musisz zaimplementować małą maszynę stanową tylko po to, aby śledzić, kiedy należy w ogóle wykonać pracę.
Czy nie byłoby fajnie, gdyby zamiast jawnego śledzenia całego tego stanu w wielu ramkach i zamiast wielowątkowości i zarządzania synchronizacją i blokowaniem itd., Mógłbyś po prostu napisać swoją funkcję jako pojedynczy fragment kodu i zaznaczyć konkretne miejsca, w których funkcja powinna się „zatrzymać” i kontynuować w późniejszym czasie?
Jedność - wraz z wieloma innymi środowiskami i językami - zapewnia to w postaci Coroutines.
Jak wyglądają? W „Unityscript” (Javascript):
function LongComputation()
{
while(someCondition)
{
yield;
}
}
W C #:
IEnumerator LongComputation()
{
while(someCondition)
{
yield return null;
}
}
Jak oni pracują? Powiem szybko, że nie pracuję dla Unity Technologies. Nie widziałem kodu źródłowego Unity. Nigdy nie widziałem wnętrzności podstawowego silnika Unity. Jeśli jednak zaimplementowali to w sposób radykalnie różny od tego, co mam zamiar opisać, to będę dość zaskoczony. Jeśli ktoś z UT chciałby się do niego włączyć i porozmawiać o tym, jak to naprawdę działa, byłoby świetnie.
Duże wskazówki znajdują się w wersji C #. Po pierwsze, zwróć uwagę, że typem zwracanym dla funkcji jest IEnumerator. Po drugie, zwróć uwagę, że jednym ze stwierdzeń jest zwrot zysku. Oznacza to, że yield musi być słowem kluczowym, a ponieważ obsługa języka C # w Unity to vanilla C # 3.5, musi to być słowo kluczowe vanilla C # 3.5. Rzeczywiście, tutaj jest w MSDN - mowa o czymś, co nazywa się „blokami iteratorów”. Więc co się dzieje?
Po pierwsze, istnieje ten typ IEnumerator. Typ IEnumerator działa jak kursor nad sekwencją, zapewniając dwa znaczące elementy członkowskie: Current, która jest właściwością dającą element, nad którym obecnie znajduje się kursor, oraz MoveNext (), funkcję, która przechodzi do następnego elementu w sekwencji. Ponieważ IEnumerator jest interfejsem, nie określa dokładnie, w jaki sposób te elementy członkowskie są implementowane; MoveNext () może po prostu dodać jedną wartość doCurrent lub może załadować nową wartość z pliku lub może pobrać obraz z Internetu i zaszyfrować go i zapisać nowy hash w Current… lub może nawet zrobić jedną rzecz za pierwszą element w sekwencji i coś zupełnie innego dla drugiego. Możesz nawet użyć go do wygenerowania nieskończonej sekwencji, jeśli chcesz. MoveNext () oblicza następną wartość w sekwencji (zwraca false, jeśli nie ma więcej wartości),
Zwykle, jeśli chciałbyś zaimplementować interfejs, musiałbyś napisać klasę, zaimplementować członków i tak dalej. Bloki iteratora to wygodny sposób implementacji IEnumerator bez wszystkich kłopotów - wystarczy przestrzegać kilku reguł, a implementacja IEnumerator jest generowana automatycznie przez kompilator.
Blok iteratora to zwykła funkcja, która (a) zwraca IEnumerator i (b) używa słowa kluczowego yield. Co właściwie robi słowo kluczowe zysku? Deklaruje, jaka jest następna wartość w sekwencji - lub że nie ma więcej wartości. Punkt, w którym kod napotyka zwrot zysku X lub przerwanie zysku, jest punktem, w którym IEnumerator.MoveNext () powinien się zatrzymać; a yield return X powoduje, że MoveNext () zwraca prawdę, a właściwość Current ma przypisaną wartość X, podczas gdy podział zysku powoduje, że MoveNext () zwraca wartość false.
Oto sztuczka. Nie musi mieć znaczenia, jakie są rzeczywiste wartości zwracane przez sekwencję. Możesz wielokrotnie wywoływać MoveNext () i ignorować Current; obliczenia będą nadal wykonywane. Za każdym razem, gdy wywoływana jest funkcja MoveNext (), blok iteratora przechodzi do następnej instrukcji „yield”, niezależnie od tego, jakie wyrażenie faktycznie daje. Możesz więc napisać coś takiego:
IEnumerator TellMeASecret()
{
PlayAnimation("LeanInConspiratorially");
while(playingAnimation)
yield return null;
Say("I stole the cookie from the cookie jar!");
while(speaking)
yield return null;
PlayAnimation("LeanOutRelieved");
while(playingAnimation)
yield return null;
}
tak naprawdę napisałeś blok iteratora, który generuje długą sekwencję wartości null, ale najważniejsze są efekty uboczne pracy, jaką wykonuje, aby je obliczyć. Możesz uruchomić ten program za pomocą prostej pętli, takiej jak ta:
IEnumerator e = TellMeASecret();
while(e.MoveNext()) { }
Lub, bardziej użytecznie, możesz połączyć to z innymi pracami:
IEnumerator e = TellMeASecret();
while(e.MoveNext())
{
if(Input.GetKeyDown(KeyCode.Escape)) { break; }
}
Wszystko zależy od czasu Jak już widzieliśmy, każda instrukcja yield return musi zawierać wyrażenie (np. Null), aby blok iteratora miał coś do przypisania do IEnumerator.Current. Długa sekwencja wartości zerowych nie jest dokładnie przydatna, ale bardziej interesują nas skutki uboczne. Prawda?
Właściwie jest coś przydatnego, co możemy zrobić z tym wyrażeniem. A co by było, gdybyśmy zamiast po prostu dać wartość null i ją zignorować, uzyskalibyśmy coś, co wskazywało, kiedy spodziewamy się, że będziemy musieli wykonać więcej pracy? Często będziemy musieli przejść bezpośrednio do następnej klatki, oczywiście, ale nie zawsze: będzie wiele razy, w których będziemy chcieli kontynuować po zakończeniu odtwarzania animacji lub dźwięku lub po upływie określonego czasu. Te while (playingAnimation) dają zwrot null; konstrukcje są trochę uciążliwe, nie sądzisz?
Unity deklaruje typ podstawowy YieldInstruction i udostępnia kilka konkretnych typów pochodnych, które wskazują określone rodzaje oczekiwania. Masz WaitForSeconds, który wznawia coroutine po upływie wyznaczonego czasu. Masz WaitForEndOfFrame, który wznawia coroutine w określonym punkcie później w tej samej klatce. Masz sam typ Coroutine, który, gdy coroutine A daje coroutine B, zatrzymuje coroutine A aż do zakończenia programu B.
Jak to wygląda z punktu widzenia środowiska wykonawczego? Jak powiedziałem, nie pracuję dla Unity, więc nigdy nie widziałem ich kodu; ale wyobrażam sobie, że może to wyglądać trochę tak:
List<IEnumerator> unblockedCoroutines;
List<IEnumerator> shouldRunNextFrame;
List<IEnumerator> shouldRunAtEndOfFrame;
SortedList<float, IEnumerator> shouldRunAfterTimes;
foreach(IEnumerator coroutine in unblockedCoroutines)
{
if(!coroutine.MoveNext())
continue;
if(!coroutine.Current is YieldInstruction)
{
shouldRunNextFrame.Add(coroutine);
continue;
}
if(coroutine.Current is WaitForSeconds)
{
WaitForSeconds wait = (WaitForSeconds)coroutine.Current;
shouldRunAfterTimes.Add(Time.time + wait.duration, coroutine);
}
else if(coroutine.Current is WaitForEndOfFrame)
{
shouldRunAtEndOfFrame.Add(coroutine);
}
else
}
unblockedCoroutines = shouldRunNextFrame;
Nietrudno sobie wyobrazić, jak można dodać więcej podtypów YieldInstruction, aby obsłużyć inne przypadki - na przykład można dodać obsługę sygnałów na poziomie silnika, z obsługą WaitForSignal („SignalName”) YieldInstruction. Dodając więcej YieldInstructions, same programy mogą stać się bardziej wyraziste - yield return new WaitForSignal ("GameOver") jest przyjemniejszy do czytania niż podczas gdy (! Signals.HasFired ("GameOver")) zwracają wartość null, jeśli o mnie chodzi, zupełnie poza fakt, że zrobienie tego w silniku mogłoby być szybsze niż zrobienie tego w skrypcie.
Kilka nieoczywistych konsekwencji Jest kilka przydatnych rzeczy w tym wszystkim, których ludzie czasami pomijają, a które moim zdaniem powinny zwrócić uwagę.
Po pierwsze, zwrot zysku daje po prostu wyrażenie - dowolne wyrażenie - a YieldInstruction jest typem regularnym. Oznacza to, że możesz wykonywać takie czynności jak:
YieldInstruction y;
if(something)
y = null;
else if(somethingElse)
y = new WaitForEndOfFrame();
else
y = new WaitForSeconds(1.0f);
yield return y;
Konkretne wiersze dają return new WaitForSeconds (), yield return new WaitForEndOfFrame () itd. Są powszechne, ale nie są w rzeczywistości specjalnymi formami same w sobie.
Po drugie, ponieważ te programy są po prostu blokami iteratorów, możesz je samodzielnie iterować, jeśli chcesz - nie musisz mieć silnika, aby robił to za Ciebie. Używałem tego do dodawania warunków przerwań do programu wcześniej:
IEnumerator DoSomething()
{
}
IEnumerator DoSomethingUnlessInterrupted()
{
IEnumerator e = DoSomething();
bool interrupted = false;
while(!interrupted)
{
e.MoveNext();
yield return e.Current;
interrupted = HasBeenInterrupted();
}
}
Po trzecie, fakt, że możesz ustąpić miejsca innym programom, może w pewnym sensie pozwolić ci na wdrożenie własnych YieldInstructions, chociaż nie tak wydajnie, jakby były implementowane przez silnik. Na przykład:
IEnumerator UntilTrueCoroutine(Func fn)
{
while(!fn()) yield return null;
}
Coroutine UntilTrue(Func fn)
{
return StartCoroutine(UntilTrueCoroutine(fn));
}
IEnumerator SomeTask()
{
yield return UntilTrue(() => _lives < 3);
}
jednak tak naprawdę nie polecałbym tego - koszt rozpoczęcia Coroutine jest trochę wysoki jak na mój gust.
Wniosek Mam nadzieję, że to wyjaśnia trochę tego, co naprawdę dzieje się, gdy używasz Coroutine in Unity. Bloki iteratora C # to fajna mała konstrukcja, a nawet jeśli nie używasz Unity, być może uznasz, że przydatne będzie ich wykorzystanie w ten sam sposób.
IEnumerator
/IEnumerable
(lub generyczne odpowiedniki) i które zawierająyield
słowo kluczowe. Wyszukaj iteratory.