Как шаблон StartCoroutine / yield return работает в Unity?

134

Я понимаю принцип сопрограмм. Я знаю, как заставить стандарт StartCoroutine/ yield returnшаблон работать на C # в Unity, например, вызвать метод, возвращающийся IEnumeratorчерез, StartCoroutineи в этом методе что-то сделать, сделать, yield return new WaitForSeconds(1);чтобы подождать секунду, а затем сделать что-то еще.

У меня вопрос: что на самом деле происходит за кулисами? Что на StartCoroutineсамом деле делает? Что IEnumeratorэто WaitForSecondsвозвращение? Как StartCoroutineвернуть управление «чему-то еще» в вызываемом методе? Как все это взаимодействует с моделью параллелизма Unity (где множество вещей происходит одновременно без использования сопрограмм)?

Ghopper21
источник
3
Компилятор C # преобразует методы, возвращающие IEnumerator/ IEnumerable(или универсальные эквиваленты) и содержащие yieldключевое слово. Найдите итераторы.
Damien_The_Unbeliever
4
Итератор - очень удобная абстракция для «конечного автомата». Сначала поймите это, и вы также получите сопрограммы Unity. en.wikipedia.org/wiki/State_machine
Ханс Пассан
2
Тег единства зарезервирован Microsoft Unity. Пожалуйста, не злоупотребляйте им.
Лекс Ли
11
Я нашел эту статью довольно проясняющей: подробности о сопрограммах Unity3D
Кей
5
@ Кей, я бы хотел купить тебе пива. Эта статья - именно то, что мне нужно. Я начал сомневаться в своем здравом уме, поскольку казалось, что мой вопрос даже не имеет смысла, но статья прямо отвечает на мой вопрос лучше, чем я мог себе представить. Возможно, вы можете добавить ответ по этой ссылке, которую я могу принять для будущих пользователей SO?
Ghopper21

Ответы:

109

Часто упоминаемая ссылка подробных сведений о сопрограммах Unity3D мертва. Поскольку это упоминается в комментариях и ответах, я опубликую здесь содержание статьи. Этот контент исходит из этого зеркала .


Подробнее о сопрограммах Unity3D

Многие процессы в играх происходят в течение нескольких кадров. У вас есть «плотные» процессы, такие как поиск пути, которые усердно работают с каждым кадром, но разделяются на несколько кадров, чтобы не слишком сильно влиять на частоту кадров. У вас есть «разреженные» процессы, такие как триггеры игрового процесса, которые ничего не делают в большинстве кадров, но иногда требуются для выполнения важной работы. И у вас есть разные процессы между ними.

Всякий раз, когда вы создаете процесс, который будет происходить в нескольких кадрах - без многопоточности - вам нужно найти способ разбить работу на части, которые можно запускать по одному на кадр. Для любого алгоритма с центральным циклом это довольно очевидно: например, поисковик A * может быть структурирован таким образом, чтобы он постоянно поддерживал свои списки узлов, обрабатывая только несколько узлов из открытого списка в каждом кадре, вместо того, чтобы пытаться делать всю работу за один раз. Чтобы управлять задержкой, необходимо выполнить некоторую балансировку - в конце концов, если вы фиксируете частоту кадров на уровне 60 или 30 кадров в секунду, тогда ваш процесс будет занимать только 60 или 30 шагов в секунду, и это может привести к тому, что процесс просто займет слишком долго в целом. Аккуратный дизайн может предлагать минимально возможную единицу работы на одном уровне - например, обрабатывать один узел A * - и слой наверху для группировки работы в более крупные блоки - например, продолжать обработку узлов A * в течение X миллисекунд. (Некоторые люди называют это «временным разрезом», хотя я не говорю).

Тем не менее, позволяя таким образом разбивать работу, вы должны передавать состояние от одного кадра к другому. Если вы нарушаете итерационный алгоритм, вам необходимо сохранить все состояние, разделяемое между итерациями, а также средства отслеживания, какая итерация будет выполняться следующей. Обычно это не так уж плохо - дизайн «класса следопыта A *» довольно очевиден - но есть и другие случаи, менее приятные. Иногда вы столкнетесь с долгими вычислениями, выполняющими разные виды работы от кадра к кадру; объект, фиксирующий их состояние, может закончиться большим беспорядком из полу-полезных «локальных», сохраняемых для передачи данных от одного кадра к другому. А если вы имеете дело с разреженным процессом, вам часто приходится внедрять небольшой конечный автомат, чтобы отслеживать, когда вообще следует выполнять работу.

Было бы здорово, если бы вместо того, чтобы явно отслеживать все это состояние в нескольких фреймах и вместо многопоточности и управления синхронизацией, блокировкой и т. Д., Вы могли бы просто написать свою функцию как единый фрагмент кода, и отметить конкретные места, где функция должна «приостановиться» и продолжиться позже?

Unity - наряду с рядом других сред и языков - предоставляет это в виде сопрограмм.

Как они выглядят? В «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. Однако, если они реализовали это способом, радикально отличным от того, что я собираюсь описать, я буду очень удивлен. Если кто-нибудь из UT захочет вмешаться и поговорить о том, как это работает на самом деле, это было бы здорово.

Основные подсказки находятся в версии для C #. Во-первых, обратите внимание, что тип возвращаемого значения для функции - IEnumerator. А во-вторых, обратите внимание, что одно из утверждений - yield return. Это означает, что yield должен быть ключевым словом, а поскольку Unity поддерживает C # в обычном C # 3.5, это должно быть ключевое слово vanilla C # 3.5. В самом деле, здесь он находится в MSDN - речь идет о чем-то, что называется «блоками итератора». Так что же происходит?

Во-первых, это тип IEnumerator. Тип IEnumerator действует как курсор над последовательностью, предоставляя два важных члена: Current, которое является свойством, дающим вам элемент, над которым сейчас находится курсор, и MoveNext (), функция, которая перемещается к следующему элементу в последовательности. Поскольку IEnumerator является интерфейсом, он не определяет, как именно реализованы эти члены; MoveNext () может просто добавить один кCurrent, или он может загрузить новое значение из файла, или он может загрузить изображение из Интернета и хэшировать его и сохранить новый хеш в Current ... или он может даже сделать что-то одно для первого элемент в последовательности, и что-то совсем другое для второго. Вы даже можете использовать его для создания бесконечной последовательности, если хотите. MoveNext () вычисляет следующее значение в последовательности (возвращает false, если значений больше нет),

Обычно, если вы хотите реализовать интерфейс, вам нужно написать класс, реализовать члены и так далее. Блоки итератора - это удобный способ реализации IEnumerator без всех этих хлопот - вы просто следуете нескольким правилам, и реализация IEnumerator создается компилятором автоматически.

Блок итератора - это обычная функция, которая (а) возвращает IEnumerator, и (б) использует ключевое слово yield. Так что же на самом деле делает ключевое слово yield? Он объявляет, какое следующее значение в последовательности - или что значений больше нет. Точка, в которой код встречает yield return X или yield break, является точкой, в которой IEnumerator.MoveNext () должен остановиться; доходность return X заставляет MoveNext () возвращать true иCurrent присваивать значение X, в то время как разрыв доходности заставляет MoveNext () возвращать false.

Теперь вот трюк. Не имеет значения, каковы фактические значения, возвращаемые последовательностью. Вы можете повторно вызывать MoveNext () и игнорировать Current; вычисления по-прежнему будут выполняться. Каждый раз, когда вызывается 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; }
}

Все зависит от времени. Как вы видели, каждый оператор yield return должен предоставлять выражение (например, null), чтобы блоку итератора было что-то присвоить IEnumerator.Current. Длинная последовательность нулей не совсем полезна, но нас больше интересуют побочные эффекты. Не так ли?

На самом деле, с этим выражением можно сделать что-то полезное. Что, если вместо того, чтобы просто выдавать значение null и игнорировать его, мы получили что-то, что указывало бы, когда мы ожидаем, что нам потребуется выполнить больше работы? Часто нам нужно будет сразу перейти к следующему кадру, конечно, но не всегда: будет много раз, когда мы захотим продолжить после того, как анимация или звук закончится, или по прошествии определенного времени. Те while (playsAnimation) yield return null; Конструкции немного утомительны, вам не кажется?

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, сами сопрограммы могут стать более выразительными - yield return new WaitForSignal ("GameOver") лучше читать, чем в то время как (! Signals.HasFired ("GameOver")) yield return null, если вы спросите меня, совершенно помимо тот факт, что выполнение этого в движке может быть быстрее, чем выполнение в скрипте.

Пара неочевидных ответвлений Во всем этом есть пара полезных вещей, которые люди иногда упускают, и я подумал, что должен указать на них.

Во-первых, yield return просто возвращает выражение - любое выражение - а YieldInstruction - это обычный тип. Это означает, что вы можете делать что-то вроде:

YieldInstruction y;

if(something)
 y = null;
else if(somethingElse)
 y = new WaitForEndOfFrame();
else
 y = new WaitForSeconds(1.0f);

yield return y;

Конкретные строки yield return new WaitForSeconds (), yield return new WaitForEndOfFrame () и т. Д. Являются обычными, но сами по себе они не являются специальными формами.

Во-вторых, поскольку эти сопрограммы являются просто блоками итераторов, вы можете перебирать их самостоятельно, если хотите - вам не нужно, чтобы движок делал это за вас. Я использовал это для добавления условий прерывания в сопрограмму раньше:

IEnumerator DoSomething()
{
  /* ... */
}

IEnumerator DoSomethingUnlessInterrupted()
{
  IEnumerator e = DoSomething();
  bool interrupted = false;
  while(!interrupted)
  {
    e.MoveNext();
    yield return e.Current;
    interrupted = HasBeenInterrupted();
  }
}

В-третьих, тот факт, что вы можете уступать другим сопрограммам, может как бы позволить вам реализовать свои собственные YieldInstructions, хотя и не так эффективно, как если бы они были реализованы движком. Например:

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, на мой взгляд, немного высока.

Заключение Я надеюсь, что это немного проясняет некоторые из того, что на самом деле происходит, когда вы используете Coroutine в Unity. Блоки итераторов C # - это отличная маленькая конструкция, и даже если вы не используете Unity, возможно, вы сочтете полезным воспользоваться ими таким же образом.

Джеймс МакМэхон
источник
2
Спасибо, что воспроизвели это здесь. Это превосходно, и мне очень помогло.
Найкровек
96

Первый заголовок ниже - прямой ответ на вопрос. Два заголовка после них более полезны для обычного программиста.

Возможно скучные детали реализации сопрограмм

Сопрограммы описаны в Википедии и других местах. Здесь я просто представлю некоторые детали с практической точки зрения. IEnumerator, yieldи т. д. - это функции языка C # , которые используются в Unity для иных целей.

Проще говоря, объект IEnumeratorутверждает, что у него есть набор значений, которые вы можете запрашивать одно за другим, вроде как List. В C # функция с подписью для возврата IEnumeratorне обязана фактически создавать и возвращать ее, но может позволить C # предоставлять неявное IEnumerator. Затем функция может IEnumeratorлениво предоставлять содержимое того, что будет возвращено в будущем, посредством yield returnоператоров. Каждый раз, когда вызывающий объект запрашивает другое значение из этого неявного выражения, IEnumeratorфункция выполняется до следующего yield returnоператора, который предоставляет следующее значение. Как побочный продукт этого, функция приостанавливается до тех пор, пока не будет запрошено следующее значение.

В Unity мы не используем их для предоставления будущих значений, мы используем тот факт, что функция приостанавливается. Из-за этой эксплуатации многие вещи в сопрограммах в Unity не имеют смысла (причем здесь что- IEnumeratorто общего? Что такое yield? Почему new WaitForSeconds(3)? И т. Д.). Что происходит «под капотом»: значения, которые вы предоставляете через IEnumerator, используются, StartCoroutine()чтобы решить, когда запрашивать следующее значение, которое определяет, когда ваша сопрограмма снова возобновит работу.

Ваша игра Unity является однопоточной (*)

Сопрограммы - это не потоки. В Unity есть один основной цикл, и все те функции, которые вы пишете, вызываются одним и тем же основным потоком по порядку. Вы можете проверить это, поместив while(true);в любую из ваших функций или сопрограмм. Это заморозит все, даже редактор Unity. Это свидетельство того, что все работает в одном основном потоке. Эта ссылка, которую Кей упомянул в своем комментарии выше, также является отличным ресурсом.

(*) Unity вызывает ваши функции из одного потока. Итак, если вы сами не создаете поток, написанный вами код будет однопоточным. Конечно, Unity использует другие потоки, и вы можете сами создавать потоки, если хотите.

Практическое описание сопрограмм для программистов игр

В основном, когда вы звоните StartCoroutine(MyCoroutine()), это не так же , как обычный вызов функции к MyCoroutine(), до первого yield return X, где Xчто - то вроде null, new WaitForSeconds(3), StartCoroutine(AnotherCoroutine()), breakи т.д. Это когда он начинает отличаясь от функции. Unity «приостанавливает» эту функцию прямо на этой yield return Xстроке, продолжает другие дела, и некоторые кадры проходят, и когда снова приходит время, Unity возобновляет эту функцию сразу после этой строки. Он запоминает значения для всех локальных переменных в функции. Таким образом, у вас может быть forцикл, который, например, повторяется каждые две секунды.

Когда Unity возобновит вашу сопрограмму, зависит от того, что Xбыло в вашем yield return X. Например, если вы использовали yield return new WaitForSeconds(3);, он возобновится через 3 секунды. Если вы использовали yield return StartCoroutine(AnotherCoroutine()), он возобновляется после того, как AnotherCoroutine()будет полностью завершен, что позволяет вам вовремя вкладывать поведение. Если вы только что использовали a yield return null;, он возобновится прямо в следующем кадре.

Газихан Аланкус
источник
2
Это очень плохо, похоже, что UnityGems уже некоторое время не работает. Некоторым людям на Reddit удалось получить последнюю версию архива: web.archive.org/web/20140702051454/http://unitygems.com/…
ForceMagic
3
Это очень расплывчато и есть риск ошибиться. Вот как на самом деле компилируется код и почему он работает. Кроме того, это тоже не отвечает на вопрос. stackoverflow.com/questions/3438670/…
Луи Хонг
Да, наверное, я объяснил, «как работают сопрограммы в Unity» с точки зрения игрового программиста. Фактически вопрос был в том, чтобы спросить, что происходит под капотом. Если вы можете указать неправильные части моего ответа, я буду рад исправить это.
Газихан Аланкус
4
Я согласен с yield return false, я добавил его, потому что кто-то раскритиковал мой ответ за его отсутствие, и я спешил проверить, было ли оно вообще полезным, и просто добавил ссылку. Я удалил это сейчас. Однако я думаю, что Unity однопоточна и как сопрограммы в нее вписываются, не для всех очевидно. Многие начинающие программисты Unity, с которыми я разговаривал, имеют очень смутное представление обо всем этом и извлекают выгоду из такого объяснения. Я отредактировал свой ответ, чтобы дать точный ответ на вопрос. Предложения приветствуются.
Gazihan Alankus
2
Unity - это не однопоточный fwiw. У него есть основной поток, в котором выполняются методы жизненного цикла MonoBehaviour, но есть и другие потоки. Вы даже можете создавать свои собственные темы.
benthehutt
10

Это не может быть проще:

Unity (и все игровые движки) основаны на фреймах .

Вся суть, весь смысл существования Unity в том, что он основан на фреймах. Движок делает "каждый кадр" за вас. (Анимирует, отображает объекты, занимается физикой и т. Д.)

Вы можете спросить… «О, это здорово. Что, если я хочу, чтобы двигатель что-то делал для меня в каждом кадре? Как мне сказать движку делать то-то и то-то в кадре?»

Ответ ...

Именно для этого и нужна «сопрограмма».

Это так просто.

И учтите это ....

Вы знаете функцию «Обновить». Проще говоря, все, что вы туда вставляете, делается в каждом кадре . Это буквально то же самое, без каких-либо отличий от синтаксиса coroutine-yield.

void Update()
 {
 this happens every frame,
 you want Unity to do something of "yours" in each of the frame,
 put it in here
 }

...in a coroutine...
 while(true)
 {
 this happens every frame.
 you want Unity to do something of "yours" in each of the frame,
 put it in here
 yield return null;
 }

Абсолютно никакой разницы.

Сноска: как все отметили, в Unity просто нет потоков . «Фреймы» в Unity или в любом игровом движке никак не связаны с потоками.

Coroutines / yield - это просто способ доступа к фреймам в Unity. Вот и все. (И действительно, это абсолютно то же самое, что и функция Update (), предоставляемая Unity.) Вот и все, это так просто.

Fattie
источник
Спасибо! Но ваш ответ объясняет, как использовать сопрограммы, а не как они работают за кулисами.
Ghopper21
1
С удовольствием, спасибо. Я понимаю, что вы имеете в виду - это может быть хорошим ответом для новичков, которые всегда спрашивают, что такое сопрограммы. Ура!
Fattie
1
Собственно - ни один из ответов, хоть немного, не объясняет, что происходит «за кадром». (Дело в том, что это IEnumerator, который
вставляется
Вы сказали: «Нет абсолютно никакой разницы». Тогда зачем Unity создала сопрограммы, когда у них уже есть точная рабочая реализация вроде Update()? Я имею в виду, что между этими двумя реализациями и вариантами их использования должна быть хотя бы небольшая разница, которая довольно очевидна.
Leandro Gecozo
привет @LeandroGecozo - я бы сказал больше, что «Обновление» - это просто своего рода («глупое») упрощение, которое они добавили. (Многие люди никогда его не используют, просто используют сопрограммы!) Я не думаю, что есть хороший ответ на ваш вопрос, просто Unity такова.
Fattie
5

В последнее время копался в этом, написал здесь сообщение - http://eppz.eu/blog/understanding-ienumerator-in-unity-3d/ - которое проливает свет на внутренности (с примерами плотного кода), базовый IEnumeratorинтерфейс, и как это используется для сопрограмм.

Использование для этой цели перечислителей коллекций все еще кажется мне немного странным. Это противоположность тому, для чего счетчики чувствуют себя сконструированными. Точка перечислителей - это возвращаемое значение при каждом доступе, а точка сопрограмм - это код между возвращаемыми значениями. Фактическое возвращаемое значение в этом контексте бессмысленно.

Гери Борбас
источник
0

Базовые функции Unity, которые вы получаете автоматически, - это функция Start () и функция Update (), поэтому Coroutine по сути являются функциями, такими же, как функции Start () и Update (). Любую старую функцию func () можно вызвать так же, как и сопрограмму. Очевидно, что Unity установила определенные границы для сопрограмм, которые отличают их от обычных функций. Одно отличие - вместо

  void func()

Ты пишешь

  IEnumerator func()

для сопрограмм. Точно так же вы можете контролировать время в обычных функциях с помощью таких строк кода, как

  Time.deltaTime

У сопрограммы есть специальный дескриптор, позволяющий управлять временем.

  yield return new WaitForSeconds();

Хотя это не единственное, что можно делать внутри IEnumerator / Coroutine, это одна из полезных вещей, для которых используются Coroutine. Вам нужно будет изучить API сценариев Unity, чтобы узнать о других конкретных применениях Coroutines.

Alexhawkburr
источник
0

StartCoroutine - это метод вызова функции IEnumerator. Это похоже на простой вызов простой функции void, с той лишь разницей, что вы используете ее в функциях IEnumerator. Этот тип функции уникален, так как он позволяет использовать специальную функцию yield , обратите внимание, что вы должны что-то вернуть. Насколько я знаю. Здесь я написал простую игру с мерцанием над текстом в единстве

    public IEnumerator GameOver()
{
    while (true)
    {
        _gameOver.text = "GAME OVER";
        yield return new WaitForSeconds(Random.Range(1.0f, 3.5f));
        _gameOver.text = "";
        yield return new WaitForSeconds(Random.Range(0.1f, 0.8f));
    }
}

Затем я вызвал его из самого IEnumerator

    public void UpdateLives(int currentlives)
{
    if (currentlives < 1)
    {
        _gameOver.gameObject.SetActive(true);
        StartCoroutine(GameOver());
    }
}

Как видите, я использовал метод StartCoroutine (). Надеюсь, я как-то помог. Я сам новичок, поэтому, если вы поправите меня или оцените меня, любой отзыв будет отличным.

Дипаншу Мишра
источник