Часто посилання на підпрограми Unity3D докладно посилаються мертвими. Оскільки це згадується в коментарях та відповідях, я збираюся розмістити тут зміст статті. Цей вміст походить від цього дзеркала .
Детально розглядаються питання Unity3D
Багато процесів в іграх відбуваються протягом декількох кадрів. У вас є "щільні" процеси, як-от перенаправлення маршрутів, які наполегливо працюють кожен кадр, але розбиваються на кілька кадрів, щоб не впливати на кадр занадто сильно. У вас є "рідкісні" процеси, як ігрові тригери, які не роблять більшість кадрів, але періодично закликаються робити критичну роботу. І у вас є різні процеси між ними.
Кожного разу, коли ви створюєте процес, який відбуватиметься через декілька кадрів - без багатопотокової редакції, вам потрібно знайти спосіб розбиття роботи на шматки, які можна запускати один на кадр. Для будь-якого алгоритму з центральним циклом це досить очевидно: наприклад, A * pathfinder може бути структурований таким чином, що він підтримує свої списки вузлів напівперманентно, обробляючи лише декілька вузлів із відкритого списку кожен кадр, а не намагаючись виконати всю роботу за один рух. Для управління затримкою потрібно виконати балансування - адже якщо ви блокуєте частоту кадрів зі швидкістю 60 або 30 кадрів в секунду, то ваш процес займе лише 60 або 30 кроків в секунду, і це може призвести до простого процесу занадто довгий загальний. Акуратний дизайн може запропонувати найменшу можливу одиницю роботи на одному рівні - наприклад обробляти один вузол A * - і шар зверху способом групування спільної роботи в більші шматки - наприклад, тримати обробку A * вузлів протягом X мілісекунд. (Деякі люди називають це «часовим набором часу», хоча я цього не роблю).
Тим не менш, дозволяючи розбити роботу таким чином, ви повинні перевести стан з одного кадру в інший. Якщо ви порушуєте ітераційний алгоритм, то вам доведеться зберегти весь стан, поділений в рамках ітерацій, а також засіб відстеження, яку ітерацію слід виконати далі. Зазвичай це не дуже погано - дизайн класу «A * pathfinder» досить очевидний - але є й інші випадки, які є менш приємними. Іноді ви будете стикатися з довгими обчисленнями, які виконують різні види роботи від кадру до кадру; об'єкт, що захоплює їх стан, може закінчитися великим безладом напівкорисних «місцевих жителів», що зберігаються для передачі даних з одного кадру в інший. І якщо ви маєте справу з розрідженим процесом, вам часто доводиться впроваджувати невелику державну машину просто для того, щоб відслідковувати, коли роботу взагалі потрібно робити.
Чи не було б охайним, якби замість того, щоб чітко відстежувати весь цей стан через декілька кадрів, а замість того, щоб багатопотоково читати та керувати синхронізацією та блокуванням тощо, ви могли просто написати свою функцію як єдиний фрагмент коду, і позначте конкретні місця, де функція повинна «призупинятись» і виконувати її згодом?
Єдність - разом з низкою інших середовищ та мов - надає це у формі Coroutines.
Як вони виглядають? У розділі "Unityscript" (Javascript):
function LongComputation()
{
while(someCondition)
{
/* Do a chunk of work */
// Pause here and carry on next frame
yield;
}
}
В C #:
IEnumerator LongComputation()
{
while(someCondition)
{
/* Do a chunk of work */
// Pause here and carry on next frame
yield return null;
}
}
Як вони працюють? Дозвольте мені швидко сказати, що я не працюю в Unity Technologies. Я не бачив вихідного коду Unity. Я ніколи не бачив кишок механізму кореневих програм Unity. Однак, якщо вони реалізували це таким чином, що кардинально відрізняється від того, що я збираюся описати, я буду дуже здивований. Якщо хтось із УТ хоче прислухатися і поговорити про те, як це насправді працює, то це було б чудово.
Великі підказки є у версії C #. По-перше, зауважте, що типом повернення для функції є IEnumerator. По-друге, зауважте, що одне із тверджень - це прибутковість. Це означає, що вихід повинен бути ключовим словом, а оскільки підтримка C # Unity - це ваніль C # 3.5, це має бути ключове слово ванілі C # 3.5. Дійсно, ось мова йде про MSDN - про щось, що називається "блоки ітераторів". Отже, що відбувається?
По-перше, є такий тип IEnumerator. Тип IEnumerator діє як курсор над послідовністю, забезпечуючи два значущі члени: Current, що є властивістю, що дає вам елемент, над яким курсор зараз перебуває, і MoveNext (), функція, що переходить до наступного елемента в послідовності. Оскільки IEnumerator є інтерфейсом, він не вказує, як саме реалізуються ці члени; MoveNext () може просто додати один до потоку, або він може завантажити нове значення з файлу, або він може завантажити зображення з Інтернету та хеш-пам'ять і зберегти новий хеш у Current ... або він навіть може зробити одне для першого елемент у послідовності, а для другого щось зовсім інше. Ви можете навіть використовувати його для створення нескінченної послідовності, якщо цього хочете. MoveNext () обчислює наступне значення в послідовності (повертає помилкове, якщо більше немає значень),
Зазвичай, якщо ви хочете реалізувати інтерфейс, вам доведеться написати клас, реалізувати учасників тощо. Блоки ітератора - це зручний спосіб реалізації IEnumerator без усього цього клопоту - ви просто дотримуєтесь декількох правил, і реалізація IEnumerator автоматично генерується компілятором.
Блок ітератора - це звичайна функція, яка (a) повертає IEnumerator, і (b) використовує ключове слово прибутковість. Отже, що насправді робить ключове слово дохідності? Він оголошує, що таке наступне значення в послідовності - або що більше немає значень. Точка, в якій код стикається з поверненням виходу X або перервою врожайності, - це точка, в якій IEnumerator.MoveNext () повинен зупинитися; повернення прибутковості X призводить до того, що MoveNext () повертає істинне, а потоку присвоюється значення X, тоді як перерва врожайності приводить до того, що MoveNext () повертає помилкове.
Тепер ось ось хитрість. Не має значення, які фактичні значення повертаються послідовністю. Ви можете викликати MoveNext () повторно та ігнорувати Поточний; обчислення все одно будуть виконуватися. Кожен раз, коли MoveNext () викликається, ваш блок ітераторів переходить до наступного оператора 'yield', незалежно від того, який вираз він дає. Тож ви можете написати щось на кшталт:
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;
}
і те, що ви насправді написали, - це блок ітераторів, який генерує довгу послідовність нульових значень, але важливими є побічні ефекти роботи, яку він виконує для їх обчислення. Ви можете запустити цю програму, використовуючи простий цикл, подібний до цього:
IEnumerator e = TellMeASecret();
while(e.MoveNext()) { }
Або, більш корисно, ви можете змішати його з іншою роботою:
IEnumerator e = TellMeASecret();
while(e.MoveNext())
{
// If they press 'Escape', skip the cutscene
if(Input.GetKeyDown(KeyCode.Escape)) { break; }
}
Це все в термінах. Як ви бачили, кожен оператор повернення прибутковості повинен містити вираз (як null), щоб блок ітератора міг насправді присвоїти IEnumerator.Current. Довга послідовність нулів не зовсім корисна, але нас більше цікавлять побічні ефекти. Чи не ми?
Насправді ми можемо зробити щось із цим виразом. Що робити, якщо замість того, щоб просто отримати нуль і ігнорувати це, ми отримали щось, що вказувало, коли ми очікуємо, що потрібно зробити більше роботи? Часто нам потрібно перенести прямо на наступний кадр, звичайно, але не завжди: буде багато разів, коли ми хочемо продовжувати роботу після того, як анімація чи звук закінчиться відтворенням або після того, як пройде певна кількість часу. Ті, хто (грає в Анімацію), приносять нуль; конструкції трохи стомлюючі, ти не думаєш?
Unity оголошує базовий тип YieldInstruction і надає кілька конкретних похідних типів, які вказують на певні види очікування. У вас є WaitForSeconds, який відновлює процедуру після закінчення призначеного часу. У вас є WaitForEndOfFrame, який поновлює програму в певній точці пізніше в тому ж кадрі. Ви отримали сам тип Coroutine, який, коли програма A дає команду B, призупиняє програму A до тих пір, поки не закінчиться програма B.
Як це виглядає з точки зору виконання? Як я вже сказав, я не працюю для Unity, тому я ніколи не бачив їхнього коду; але я думаю, що це може виглядати трохи так:
List<IEnumerator> unblockedCoroutines;
List<IEnumerator> shouldRunNextFrame;
List<IEnumerator> shouldRunAtEndOfFrame;
SortedList<float, IEnumerator> shouldRunAfterTimes;
foreach(IEnumerator coroutine in unblockedCoroutines)
{
if(!coroutine.MoveNext())
// This coroutine has finished
continue;
if(!coroutine.Current is YieldInstruction)
{
// This coroutine yielded null, or some other value we don't understand; run it next frame.
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 /* similar stuff for other YieldInstruction subtypes */
}
unblockedCoroutines = shouldRunNextFrame;
Не важко уявити, як можна додати більше підтипів YieldInstruction для обробки інших випадків - наприклад, може бути додана підтримка сигналів на рівні двигуна з підтримкою YieldInstruction WaitForSignal ("SignalName"). Додавши більше інструкцій YieldInstructions, самі підходи можуть стати більш виразними - повернення прибутків нового WaitForSignal ("GameOver") приємніше читати, ніж час (! Signals.HasFired ("GameOver")) прибутковість повернення дорівнює нулю, якщо ви запитаєте мене, зовсім крім той факт, що робити це в двигуні може бути швидше, ніж робити це за сценарієм.
Кілька неочевидних наслідків Є кілька корисних речей у всьому цьому, що люди іноді сумують, що я вважав, що слід зазначити.
По-перше, повернення урожайності - це лише вираз - будь-який вираз - і YieldInstruction - регулярний тип. Це означає, що ви можете робити такі речі, як:
YieldInstruction y;
if(something)
y = null;
else if(somethingElse)
y = new WaitForEndOfFrame();
else
y = new WaitForSeconds(1.0f);
yield return y;
Конкретні рядки приносять прибутковість новій WaitForSeconds (), віддача повернення нова WaitForEndOfFrame () тощо, є загальними, але вони насправді не є спеціальними формами самі по собі.
По-друге, оскільки ці функції є просто блоками ітераторів, ви можете самі перебирати їх, якщо хочете - не потрібно, щоб двигун робив це за вас. Я використовував це для додавання умов переривання до підпрограми раніше:
IEnumerator DoSomething()
{
/* ... */
}
IEnumerator DoSomethingUnlessInterrupted()
{
IEnumerator e = DoSomething();
bool interrupted = false;
while(!interrupted)
{
e.MoveNext();
yield return e.Current;
interrupted = HasBeenInterrupted();
}
}
По-третє, той факт, що ви можете поступатися іншим процедурам, може допомогти вам реалізувати власні Інструкції Yield, хоч і не настільки ефективно, як якщо б вони були реалізовані двигуном. Наприклад:
IEnumerator UntilTrueCoroutine(Func fn)
{
while(!fn()) yield return null;
}
Coroutine UntilTrue(Func fn)
{
return StartCoroutine(UntilTrueCoroutine(fn));
}
IEnumerator SomeTask()
{
/* ... */
yield return UntilTrue(() => _lives < 3);
/* ... */
}
Однак я б не рекомендував цього - вартість запуску програми була дуже вагомою для мене.
Висновок Я сподіваюсь, що це трохи прояснить деякі речі, що насправді відбувається, коли ви використовуєте Coroutine в Unity. Блоки ітераторів C # 'є нелегкою конструкцією, і навіть якщо ви не використовуєте Unity, можливо, вам буде корисно таким же чином скористатися ними.
IEnumerator
/IEnumerable
(або загальні еквіваленти) і містятьyield
ключове слово. Знайдіть ітераторів.