Saltar al contenido

¿Cómo funciona realmente el patrón de retorno StartCoroutine / yield en Unity?

Solución:

El enlace detallado de las rutinas de Unity3D a las que se hace referencia a menudo está muerto. Como se menciona en los comentarios y las respuestas, voy a publicar el contenido del artículo aquí. Este contenido proviene de este espejo.


Corutinas de Unity3D en detalle

Muchos procesos en los juegos tienen lugar en el transcurso de múltiples marcos. Tiene procesos ‘densos’, como la búsqueda de rutas, que trabajan duro en cada fotograma, pero se dividen en varios fotogramas para no afectar demasiado la velocidad de fotogramas. Tienes procesos ‘escasos’, como los desencadenantes del juego, que no hacen nada en la mayoría de los fotogramas, pero ocasionalmente se les pide que realicen un trabajo crítico. Y tiene procesos variados entre los dos.

Siempre que esté creando un proceso que se llevará a cabo en varios fotogramas, sin subprocesos múltiples, debe encontrar alguna forma de dividir el trabajo en fragmentos que se puedan ejecutar uno por fotograma. Para cualquier algoritmo con un bucle central, es bastante obvio: un buscador de caminos A *, por ejemplo, puede estructurarse de manera que mantenga sus listas de nodos de forma semipermanente, procesando solo un puñado de nodos de la lista abierta en cada marco, en lugar de intentarlo para hacer todo el trabajo de una vez. Hay que hacer un equilibrio para administrar la latencia; después de todo, si está bloqueando su velocidad de cuadros a 60 o 30 cuadros por segundo, entonces su proceso solo tomará 60 o 30 pasos por segundo, y eso podría hacer que el proceso simplemente tome demasiado largo en general. Un diseño ordenado podría ofrecer la unidad de trabajo más pequeña posible en un nivel (por ejemplo, procesar un solo nodo A *) y colocar encima una forma de agrupar el trabajo en trozos más grandes, por ejemplo, seguir procesando nodos A * durante X milisegundos. (Algunas personas llaman a esto “corte de tiempo”, aunque yo no).

Aún así, permitir que el trabajo se divida de esta manera significa que debe transferir el estado de un cuadro al siguiente. Si está rompiendo un algoritmo iterativo, entonces debe preservar todo el estado compartido entre las iteraciones, así como un medio para rastrear qué iteración se realizará a continuación. Por lo general, eso no es tan malo: el diseño de una ‘clase de pionero A *’ es bastante obvio, pero también hay otros casos que son menos agradables. A veces, se enfrentará a largos cálculos que realizan diferentes tipos de trabajo de un cuadro a otro; el objeto que captura su estado puede terminar con un gran lío de “locales” semi-útiles, que se guardan para pasar datos de un fotograma al siguiente. Y si está lidiando con un proceso escaso, a menudo termina teniendo que implementar una pequeña máquina de estado solo para rastrear cuándo se debe realizar el trabajo.

¿No sería genial si, en lugar de tener que rastrear explícitamente todo este estado en múltiples marcos, y en lugar de tener que realizar múltiples subprocesos y administrar la sincronización y el bloqueo, etc., pudiera simplemente escribir su función como un solo fragmento de código, y marcar lugares en particular donde la función debería ‘pausar’ y continuar en un momento posterior?

Unity, junto con otros entornos y lenguajes, proporciona esto en forma de corrutinas.

¿Como se ven? En “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;
    }
}

¿Cómo trabajan? Permítanme decirles, rápidamente, que no trabajo para Unity Technologies. No he visto el código fuente de Unity. Nunca he visto las agallas del motor de corrutinas de Unity. Sin embargo, si lo han implementado de una manera radicalmente diferente de lo que voy a describir, entonces me sorprenderé bastante. Si alguien de UT quiere intervenir y hablar sobre cómo funciona realmente, sería genial.

Las grandes pistas están en la versión C #. En primer lugar, tenga en cuenta que el tipo de retorno de la función es IEnumerator. Y en segundo lugar, tenga en cuenta que una de las declaraciones es rendimiento de rendimiento. Esto significa que rendimiento debe ser una palabra clave, y como el soporte de C # de Unity es vainilla C # 3.5, debe ser una palabra clave vainilla C # 3.5. De hecho, aquí está en MSDN, hablando de algo llamado ‘bloques iteradores’. Entonces, ¿qué está pasando?

En primer lugar, está este tipo de IEnumerator. El tipo IEnumerator actúa como un cursor sobre una secuencia, proporcionando dos miembros significativos: Current, que es una propiedad que le da el elemento sobre el que se encuentra actualmente el cursor, y MoveNext (), una función que se mueve al siguiente elemento de la secuencia. Debido a que IEnumerator es una interfaz, no especifica exactamente cómo se implementan estos miembros; MoveNext () podría simplemente agregar uno a Current, o podría cargar el nuevo valor de un archivo, o podría descargar una imagen de Internet y hacer un hash y almacenar el nuevo hash en Current … o incluso podría hacer una cosa por primera vez. elemento en la secuencia, y algo completamente diferente para el segundo. Incluso podría usarlo para generar una secuencia infinita si así lo desea. MoveNext () calcula el siguiente valor en la secuencia (devolviendo falso si no hay más valores), y Current recupera el valor que calculó.

Normalmente, si quisiera implementar una interfaz, tendría que escribir una clase, implementar los miembros, etc. Los bloques de iteradores son una forma conveniente de implementar IEnumerator sin todas esas molestias: solo sigue algunas reglas y el compilador genera automáticamente la implementación de IEnumerator.

Un bloque de iterador es una función regular que (a) devuelve IEnumerator y (b) usa la palabra clave yield. Entonces, ¿qué hace realmente la palabra clave de rendimiento? Declara cuál es el siguiente valor en la secuencia, o que no hay más valores. El punto en el que el código encuentra un retorno de rendimiento X o una ruptura de rendimiento es el punto en el que IEnumerator.MoveNext () debería detenerse; un retorno de rendimiento X hace que MoveNext () devuelva verdadero y a Actual se le asigne el valor X, mientras que una ruptura de rendimiento hace que MoveNext () devuelva falso.

Ahora, aquí está el truco. No tiene por qué importar cuáles son los valores reales devueltos por la secuencia. Puede llamar a MoveNext () repetidamente e ignorar Current; los cálculos se seguirán realizando. Cada vez que se llama a MoveNext (), su bloque de iterador se ejecuta en la siguiente instrucción ‘rendimiento’, independientemente de la expresión que realmente produzca. Entonces puedes escribir algo como:

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;
}

y lo que realmente ha escrito es un bloque iterador que genera una secuencia larga de valores nulos, pero lo que es significativo son los efectos secundarios del trabajo que hace para calcularlos. Puede ejecutar esta corrutina usando un ciclo simple como este:

IEnumerator e = TellMeASecret();
while(e.MoveNext()) { }

O, lo que es más útil, podría combinarlo con otro trabajo:

IEnumerator e = TellMeASecret();
while(e.MoveNext()) 
{ 
  // If they press 'Escape', skip the cutscene
  if(Input.GetKeyDown(KeyCode.Escape)) { break; }
}

Todo está en el tiempo Como ha visto, cada declaración de rendimiento debe proporcionar una expresión (como nula) para que el bloque del iterador tenga algo que asignar realmente a IEnumerator.Current. Una secuencia larga de valores nulos no es exactamente útil, pero estamos más interesados ​​en los efectos secundarios. ¿No es así?

De hecho, hay algo útil que podemos hacer con esa expresión. ¿Qué pasa si, en lugar de simplemente dar un valor nulo e ignorarlo, obtenemos algo que indica cuándo esperamos necesitar hacer más trabajo? A menudo, tendremos que continuar directamente con el siguiente fotograma, claro, pero no siempre: habrá muchas veces en las que queremos continuar después de que una animación o sonido haya terminado de reproducirse, o después de que haya pasado una cantidad de tiempo particular. Aquellos mientras (playingAnimation) dan como resultado un resultado nulo; las construcciones son un poco tediosas, ¿no crees?

Unity declara el tipo base YieldInstruction y proporciona algunos tipos derivados concretos que indican tipos particulares de espera. Tienes WaitForSeconds, que reanuda la corrutina una vez transcurrido el tiempo designado. Tienes WaitForEndOfFrame, que reanuda la corrutina en un punto particular más adelante en el mismo marco. Tiene el tipo Coroutine en sí mismo, que, cuando la corrutina A produce la corrutina B, detiene la corrutina A hasta que la corrutina B haya finalizado.

¿Cómo se ve esto desde el punto de vista del tiempo de ejecución? Como dije, no trabajo para Unity, así que nunca he visto su código; pero me imagino que podría verse un poco así:

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;

No es difícil imaginar cómo se podrían agregar más subtipos de YieldInstruction para manejar otros casos; por ejemplo, se podría agregar soporte a nivel de motor para señales, con un WaitForSignal (“SignalName”) YieldInstruction que lo respalde. Al agregar más YieldInstructions, las corrutinas en sí pueden volverse más expresivas: rendimiento return new WaitForSignal (“GameOver”) es más agradable de leer que mientras (! Signals.HasFired (“GameOver”)) rendimiento return null, si me preguntas, aparte de el hecho de que hacerlo en el motor podría ser más rápido que hacerlo en un script.

Un par de ramificaciones no obvias Hay un par de cosas útiles sobre todo esto que la gente a veces pasa por alto y que pensé que debería señalar.

En primer lugar, el rendimiento de rendimiento es simplemente producir una expresión, cualquier expresión, y YieldInstruction es un tipo regular. Esto significa que puede hacer cosas como:

YieldInstruction y;

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

yield return y;

Las líneas específicas producen return new WaitForSeconds (), yield return new WaitForEndOfFrame (), etc., son comunes, pero en realidad no son formas especiales por derecho propio.

En segundo lugar, debido a que estas corrutinas son solo bloques de iteradores, puede iterar sobre ellas usted mismo si lo desea; no es necesario que el motor lo haga por usted. He usado esto para agregar condiciones de interrupción a una corrutina antes:

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

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

En tercer lugar, el hecho de que pueda ceder en otras corrutinas puede permitirle implementar sus propias instrucciones de rendimiento, aunque no tan eficazmente como si fueran implementadas por el motor. Por ejemplo:

IEnumerator UntilTrueCoroutine(Func fn)
{
   while(!fn()) yield return null;
}

Coroutine UntilTrue(Func fn)
{
  return StartCoroutine(UntilTrueCoroutine(fn));
}

IEnumerator SomeTask()
{
  /* ... */
  yield return UntilTrue(() => _lives < 3);
  /* ... */
}

sin embargo, realmente no recomendaría esto: el costo de comenzar una Coroutine es un poco elevado para mi gusto.

Conclusión Espero que esto aclare un poco lo que realmente está sucediendo cuando usas una Coroutine en Unity. Los bloques de iteradores de C # son una construcción pequeña y maravillosa, e incluso si no está usando Unity, tal vez le resulte útil aprovecharlos de la misma manera.

El primer encabezado a continuación es una respuesta directa a la pregunta. Los dos títulos siguientes son más útiles para el programador cotidiano.

Detalles de implementación posiblemente aburridos de corrutinas

Las corrutinas se explican en Wikipedia y en otros lugares. Aquí solo proporcionaré algunos detalles desde un punto de vista práctico. IEnumerator, yield, etc.son características del lenguaje C # que se utilizan para un propósito diferente en Unity.

En pocas palabras, una IEnumerator afirma tener una colección de valores que puede solicitar uno por uno, como un List. En C #, una función con una firma para devolver un IEnumerator no tiene que crear y devolver uno, pero puede permitir que C # proporcione un IEnumerator. La función luego puede proporcionar el contenido de lo devuelto IEnumerator en el futuro de forma perezosa, a través de yield return declaraciones. Cada vez que la persona que llama pide otro valor de ese implícito IEnumerator, la función se ejecuta hasta el siguiente yield return declaración, que proporciona el siguiente valor. Como subproducto de esto, la función se detiene hasta que se solicita el siguiente valor.

En Unity, no los usamos para proporcionar valores futuros, aprovechamos el hecho de que la función se detiene. Debido a esta explotación, muchas cosas sobre las corrutinas en Unity no tienen sentido (¿Qué IEnumerator tiene que ver con algo? Que es yield? Por qué new WaitForSeconds(3)? etc.). Lo que sucede “bajo el capó” es que los valores que proporcionas a través de IEnumerator son utilizados por StartCoroutine() para decidir cuándo solicitar el siguiente valor, que determina cuándo se reanudará la pausa de la corrutina.

Tu juego de Unity es de un solo hilo

Las corrutinas son no hilos. Hay un bucle principal de Unity y todas las funciones que escribe están siendo llamadas por el mismo hilo principal en orden. Puede verificar esto colocando un while(true); en cualquiera de sus funciones o rutinas. Congelará todo, incluso el editor de Unity. Esta es una prueba de que todo se ejecuta en un hilo principal. Este enlace que Kay mencionó en su comentario anterior también es un gran recurso.

Unity llama a tus funciones desde un hilo. Entonces, a menos que cree un hilo usted mismo, el código que escribió es de un solo hilo. Por supuesto, Unity emplea otros subprocesos y usted puede crear subprocesos usted mismo si lo desea.

Una descripción práctica de corrutinas para programadores de juegos StartCoroutine(MyCoroutine())Básicamente, cuando llamas MyCoroutine(), es exactamente como una llamada de función normal a yield return X, hasta el primero X , dónde nulles algo como new WaitForSeconds(3), StartCoroutine(AnotherCoroutine()), break, yield return X , etc. Aquí es cuando comienza a diferir de una función. Unity “pausa” esa función justo en ese for línea, continúa con otros asuntos y algunos marcos pasan, y cuando llega el momento de nuevo, Unity reanuda esa función justo después de esa línea. Recuerda los valores de todas las variables locales de la función. De esta manera, puede tener un

bucle que se repite cada dos segundos, por ejemplo. X Cuándo Unity reanudará su corrutina depende de qué yield return Xestaba en tu yield return new WaitForSeconds(3);. Por ejemplo, si usaste yield return StartCoroutine(AnotherCoroutine()), se reanuda después de que hayan pasado 3 segundos. Si usaste AnotherCoroutine() , se reanuda después yield return null;está completamente hecho, lo que le permite anidar comportamientos en el tiempo. Si solo usaras un

, se reanuda justo en el siguiente fotograma.

No podría ser más sencillo: Unity (y todos los motores de juegos) sonbasado en marco

. Todo el punto, toda la razón de ser de Unity, es que se basa en marcos. El motor hace las cosas “cada cuadro” por usted.

(Anima, renderiza objetos, hace física, etc.)

Podrías preguntar … “Oh, eso es genial. ¿Qué pasa si quiero que el motor haga algo por mí en cada cuadro? ¿Cómo le digo al motor que haga tal y cual en un cuadro?”

La respuesta es …

Para eso es exactamente una “corrutina”.

Es así de simple.

Una nota sobre la función “Actualizar” … En pocas palabras, todo lo que ingrese en “Actualizar” está hecho.cada fotograma

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;
 }

. Es literalmente exactamente lo mismo, sin ninguna diferencia, de la sintaxis de rendimiento de rutina.

No hay absolutamente ninguna diferencia.

Los hilos no tienen absolutamente ninguna conexión con los marcos / corrutinas, de ninguna manera. No hay conexión alguna. Los fotogramas de un motor de juego tienenabsolutamente ninguna conexión a hilos

, de cualquier manera. Son temas completa, total y absolutamente no relacionados. (A menudo escuchas que “¡Unity es de un solo subproceso!” Tenga en cuenta que incluso esa declaración es muy confusa. Los marcos / corrutinas simplemente no tienen absolutamente ninguna conexión con el enhebrado. ¡¡Si Unity fuera multiproceso, hiperproceso o se ejecutara en una computadora cuántica !! … solo tendría sin conexión alguna

a marcos / corrutinas. Es un tema completamente, total, absolutamente no relacionado).

La computación cuántica no tiene absolutamente ninguna conexión con los marcos / corrutinas, de ninguna manera. No hay conexión alguna.

¡¡Solo para repetir !! ¡¡Si Unity fuera multiproceso, hiperproceso o se ejecutara en una computadora cuántica !! … solo tendría sin conexión alguna

a marcos / corrutinas. Es un tema completamente, total, absolutamente no relacionado.

Entonces, en resumen …

Entonces, Coroutines / yield es simplemente cómo accede a los marcos en Unity. Eso es todo.

(Y, de hecho, es absolutamente igual que la función Update () proporcionada por Unity).

Eso es todo, es así de simple.

¿Por qué IEnumerator?

No podría ser más simple: IEnumerator devuelve cosas “una y otra vez”.

(Esa lista de cosas puede tener una longitud específica como “10 cosas” o simplemente continuar para siempre).

(Puede devolver una cosa, como un número entero, o, como con cualquier función, puede simplemente “devolver”, es decir, devolver vacío).

Por lo tanto, evidentemente, un IEnumerator es lo que usaría.

En cualquier lugar de .Net que desee volver una y otra vez, IEnumerator existe para este propósito.

Toda la computación basada en marcos, con .Net, por supuesto, usa IEnumerator para devolver cada marco. ¿Qué más podría usar?

(Si es nuevo en C #, tenga en cuenta que IEnumerator también se usa para devolver cosas “ordinarias” una por una, como simplemente los elementos de una matriz, etc.)

¡Haz clic para puntuar esta entrada!
(Votos: 0 Promedio: 0)



Utiliza Nuestro Buscador

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *