¿Qué estás buscando?
Engine & platform

Corrección de Time.deltaTime en Unity 2020.2 para una jugabilidad más fluida: ¿Qué fue lo que hizo falta?

TAUTVYDAS ŽILYS / UNITY TECHNOLOGIESContributor
Oct 1, 2020|18 minutos
Corrección de Time.deltaTime en Unity 2020.2 para una jugabilidad más fluida: ¿Qué fue lo que hizo falta?
Para tu comodidad, tradujimos esta página mediante traducción automática. No podemos garantizar la precisión ni la confiabilidad del contenido traducido. Si tienes alguna duda sobre la precisión del contenido traducido, consulta la versión oficial en inglés de la página web.

La versión beta de Unity 2020.2 presenta una solución a un problema que afecta a muchas plataformas de desarrollo: valores Time.deltaTime inconsistentes, que provocan movimientos bruscos y entrecortados. Lee esta publicación del blog para comprender qué estaba sucediendo y cómo la próxima versión de Unity te ayuda a crear una jugabilidad un poco más fluida.

Desde los albores de los videojuegos, lograr un movimiento independiente de la velocidad de cuadros en los videojuegos implicaba tener en cuenta el tiempo delta de cuadros:

void Update()
{
    transform.position += m_Velocity * Time.deltaTime;
}

Con esto se logra el efecto deseado de un objeto que se mueve a una velocidad promedio constante, independientemente de la velocidad de cuadros en la que se ejecuta el juego. En teoría, también debería mover el objeto a un ritmo constante si la velocidad de cuadros es estable. En la práctica, el panorama es muy diferente. Si observaste los valores reales informados de Time.deltaTime, es posible que hayas visto esto:

6.854 ms
7.423 ms
6.691 ms
6.707 ms
7.045 ms
7.346 ms
6.513 ms

Este es un problema que afecta a muchos motores de juego, incluido Unity , y agradecemos a nuestros usuarios por informarnos al respecto. Afortunadamente, la versión beta de Unity 2020.2 comienza a abordarlo.

Entonces ¿por qué sucede esto? ¿Por qué, cuando la velocidad de cuadros está bloqueada en 144 fps constantes, Time.deltaTime no es igual a 1⁄144 segundos (~6,94 ms) cada vez? En esta publicación de blog, lo acompañaré en el viaje de investigar y, en última instancia, solucionar este fenómeno.

¿Qué es el tiempo delta y por qué es importante?

En términos sencillos, el tiempo delta es la cantidad de tiempo que tardó en completarse el último fotograma. Suena simple, pero no es tan intuitivo como se podría pensar. En la mayoría de los libros de desarrollo de juegos encontrarás esta definición canónica de un bucle de juego:

while (true)
{
  ProcessInput();
  Update();
  Render();
}

Con un bucle de juego como este, es fácil calcular el tiempo delta:

var time = GetTime();
while (true)
{
  var lastTime = time;
  time = GetTime();
  var deltaTime = time - lastTime;
  ProcessInput();
  Update(deltaTime);
  Render(deltaTime);
}

Si bien este modelo es simple y fácil de entender, es altamente inadecuado para los motores de juegos modernos. Para lograr un alto rendimiento, los motores actuales utilizan una técnica llamada “pipelining”, que permite que un motor funcione en más de un chasis a la vez.

Compare esto:

Marco

A esto:

Marco

En ambos casos, las partes individuales del bucle del juego toman la misma cantidad de tiempo, pero el segundo caso las ejecuta en paralelo, lo que le permite generar más del doble de cuadros en la misma cantidad de tiempo. Al canalizar el motor, el tiempo del cuadro pasa de ser igual a la suma de todas las etapas de la canalización a ser igual a la más larga.

Sin embargo, incluso eso es una simplificación de lo que realmente sucede en cada cuadro del motor:

  • Cada etapa del pipeline toma una cantidad de tiempo diferente en cada cuadro. Quizás este fotograma tiene más objetos en la pantalla que el anterior, lo que haría que el renderizado tome más tiempo. O tal vez el jugador giró la cara sobre el teclado, lo que hizo que el procesamiento de entrada tomara más tiempo.
  • Dado que las diferentes etapas del proceso toman distintas cantidades de tiempo, necesitamos detener artificialmente las más rápidas para que no avancen demasiado. Lo más común es que esto se implemente esperando hasta que algún cuadro anterior se voltee al búfer frontal (también conocido como búfer de pantalla). Si VSync está habilitado, esto también se sincroniza con el inicio del período VBLANK de la pantalla. Hablaré más sobre esto más adelante.

Con ese conocimiento en mente, echemos un vistazo a una línea de tiempo de cuadros típica en Unity 2020.1. Dado que la selección de la plataforma y varias configuraciones la afectan significativamente, este artículo asumirá un reproductor independiente de Windows con renderizado multiproceso habilitado, trabajos de gráficos deshabilitados, vsync habilitado y QualitySettings.maxQueuedFrames configurado en 2 ejecutándose en un monitor de 144 Hz sin perder ningún cuadro. Haga clic en la imagen para verla en tamaño completo:

Marco

La canalización de cuadros de Unity no se implementó desde cero. Más bien, evolucionó durante la última década hasta convertirse en lo que es hoy. Si regresa a versiones anteriores de Unity, encontrará que cambia cada pocas versiones.

Es posible que notes inmediatamente un par de cosas al respecto:

  • Una vez que todo el trabajo se envía a la GPU, Unity no espera a que ese fotograma se voltee a la pantalla, sino que espera al anterior. Esto está controlado por la API QualitySettings.maxQueuedFrames. Esta configuración describe qué tan lejos puede estar el marco que se está mostrando actualmente detrás del marco que se está renderizando actualmente. El valor mínimo posible es 1, ya que lo mejor que se puede hacer es renderizar framen+1 cuando framen se muestra en la pantalla. Dado que en este caso se establece en 2 (que es el valor predeterminado), Unity se asegura de que framen se muestre en la pantalla antes de comenzar a renderizar framen+2 (por ejemplo, antes de que Unity comience a renderizar frame5, espera a que frame3 aparezca en la pantalla).
  • El fotograma 5 tarda más en renderizarse en la GPU que un solo intervalo de actualización del monitor (7,22 ms frente a 6,94 ms); sin embargo, no se pierde ninguno de los fotogramas. Esto sucede porque QualitySettings.maxQueuedFrames con el valor de 2 demora el momento en que el cuadro real aparece en la pantalla, lo que produce un búfer en el tiempo que protege contra la pérdida de cuadros, siempre y cuando el "pico" no se convierta en la norma. Si se hubiera establecido en 1, Unity seguramente habría eliminado el cuadro, ya que ya no se superpondría al trabajo.

Aunque la actualización de pantalla se produce cada 6,94 ms, el muestreo de tiempo de Unity presenta una imagen diferente:

Matemáticas

El tiempo delta promedio en este caso ((7,27 + 6,64 + 7,03)/3 = 6,98 ms) está muy cerca de la frecuencia de actualización real del monitor (6,94 ms), y si lo midiera durante un período de tiempo más largo, eventualmente promediaría exactamente 6,94 ms. Desafortunadamente, si utiliza este tiempo delta tal como está para calcular el movimiento de objetos visibles, introducirá una vibración muy sutil. Para ilustrar esto, creé un proyecto de Unitysimple. Contiene tres cuadrados verdes que se mueven por el espacio mundial:

La cámara está fijada al cubo superior, por lo que aparece perfectamente fija en la pantalla. Si Time.deltaTime es preciso, los cubos del medio y de la parte inferior también parecerían estar quietos. Los cubos se mueven el doble del ancho de la pantalla cada segundo: cuanto mayor es la velocidad, más visible se vuelve la vibración. Para ilustrar el movimiento, coloqué cubos morados y rosados inmóviles en posiciones fijas en el fondo para que puedas ver qué tan rápido se mueven realmente los cubos.

En Unity 2020.1, los cubos del medio y de la parte inferior no coinciden exactamente con el movimiento del cubo superior: tiemblan levemente. A continuación se muestra un vídeo capturado con una cámara lenta (ralentizada 20x):

Identificación de la fuente de la variación del tiempo delta

Entonces, ¿de dónde vienen estas inconsistencias en el tiempo delta? La pantalla muestra cada fotograma durante un tiempo fijo, cambiando la imagen cada 6,94 ms. Este es el tiempo delta real porque ese es el tiempo que tarda un cuadro en aparecer en la pantalla y esa es la cantidad de tiempo que el jugador de tu juego observará cada cuadro.

Cada intervalo de 6,94 ms consta de dos partes: procesamiento y suspensión. La línea de tiempo del marco de ejemplo muestra que el tiempo delta se calcula en el hilo principal, por lo que será nuestro enfoque principal. La parte de procesamiento del hilo principal consiste en bombear mensajes del sistema operativo, procesar entradas, llamar a Update y emitir comandos de renderizado. “Esperar el hilo de renderizado” es la parte inactiva . La suma de estos dos intervalos es igual al tiempo real del cuadro:

Matemáticas

Ambos tiempos fluctúan por diversas razones en cada cuadro, pero su suma permanece constante. Si el tiempo de procesamiento aumenta, el tiempo de espera disminuirá y viceversa, por lo que siempre son exactamente 6,94 ms. De hecho, la suma de todas las partes que conducen a la espera siempre es igual a 6,94 ms:

Matemáticas

Sin embargo, Unity consulta el tiempo al comienzo de la actualización. Debido a eso, cualquier variación en el tiempo que lleva emitir comandos de renderizado, bombear mensajes del sistema operativo o procesar eventos de entrada alterará el resultado.

Un bucle de hilo principal simplificado de Unity se puede definir de la siguiente manera:

while (!ShouldQuit())
{
  PumpOSMessages();
  UpdateInput();
  SampleTime(); // We sample time here!
  Update();
  WaitForRenderThread();
  IssueRenderingCommands();
}

La solución a este problema parece ser sencilla: simplemente mover el muestreo de tiempo después de la espera, para que el bucle del juego se convierta en esto:

while (!ShouldQuit())
{
  PumpOSMessages();
  UpdateInput();
  Update();
  WaitForRenderThread();
  SampleTime();
  IssueRenderingCommands();
}

Sin embargo, este cambio no funciona correctamente: la renderización tiene lecturas de tiempo diferentes a las de Update(), lo que tiene efectos adversos en todo tipo de cosas. Una opción es guardar el tiempo muestreado en este punto y actualizar el tiempo del motor solo al comienzo del siguiente cuadro. Sin embargo, eso significaría que el motor estaría utilizando el tiempo anterior a la renderización del último fotograma.

Dado que mover SampleTime() después de Update() no es efectivo, tal vez mover la espera al comienzo del cuadro sea más exitoso:

while (!ShouldQuit())
{
  PumpOSMessages();
  UpdateInput();
  WaitForRenderThread();
  SampleTime();
  Update();
  IssueRenderingCommands();
}

Lamentablemente, eso causa otro problema: ahora el hilo de renderizado debe terminar de renderizarse casi tan pronto como se le solicita, lo que significa que el hilo de renderizado se beneficiará mínimamente al realizar el trabajo en paralelo.

Echemos un vistazo a la cronología del cuadro:

Marco

Unity aplica la sincronización de la canalización esperando el hilo de renderizado en cada fotograma. Esto es necesario para que el hilo principal no se ejecute demasiado antes de lo que se muestra en la pantalla. Se considera que el hilo de renderizado ha “terminado de funcionar” cuando termina de renderizar y espera a que aparezca un cuadro en la pantalla. En otras palabras, espera a que el buffer posterior se invierta y se convierta en el buffer frontal. Sin embargo, al hilo de renderizado en realidad no le importa cuándo se mostró el fotograma anterior en la pantalla; solo le importa al hilo principal porque necesita regularse a sí mismo. Entonces, en lugar de que el hilo de renderizado espere a que el cuadro aparezca en la pantalla, esta espera se puede mover al hilo principal. Lo llamaremos WaitForLastPresentation(). El bucle del hilo principal se convierte en:

while (!ShouldQuit())
{
  PumpOSMessages();
  UpdateInput();
  WaitForLastPresentation();
  SampleTime();
  Update();
  WaitForRenderThread();
  IssueRenderingCommands();
}

Ahora el tiempo se muestrea justo después de la parte de espera del bucle, por lo que el tiempo se alineará con la frecuencia de actualización del monitor. El tiempo también se muestrea al comienzo del cuadro, por lo que Update() y Render() ven los mismos tiempos.

Es muy importante tener en cuenta que WaitForLastPresention() no espera a que aparezca el fotograma - 1 en la pantalla. Si ese fuera el caso, no se realizaría ninguna canalización. En lugar de ello, espera a que aparezca framen - QualitySettings.maxQueuedFrames en la pantalla, lo que permite que el hilo principal continúe sin esperar a que se complete el último fotograma (a menos que maxQueuedFrames esté establecido en 1, en cuyo caso cada fotograma debe completarse antes de que comience uno nuevo).

Lograr la estabilidad: ¡Necesitamos profundizar más!

Después de implementar esta solución, el tiempo delta se volvió mucho más estable que antes, pero aún se producían algunas fluctuaciones y variaciones ocasionales. Dependemos de que el sistema operativo despierte el motor del modo de suspensión a tiempo. Esto puede tomar varios microsegundos y, por lo tanto, generar fluctuaciones en el tiempo delta, especialmente en plataformas de escritorio donde se ejecutan varios programas al mismo tiempo.

Para mejorar la sincronización, puedes usar la marca de tiempo exacta de un fotograma que se presenta en la pantalla (o un búfer fuera de pantalla), que la mayoría de las API/plataformas gráficas te permiten extraer. Por ejemplo, Direct3D 11 y 12 tienen IDXGISwapChain::GetFrameStatistics, mientras que macOS proporciona CVDisplayLink. Sin embargo, este enfoque tiene algunas desventajas:

  • Debe escribir un código de extracción separado para cada API de gráficos compatible, lo que significa que el código de medición de tiempo ahora es específico de la plataforma y cada plataforma tiene su propia implementación independiente. Como cada plataforma se comporta de manera diferente, un cambio como este corre el riesgo de tener consecuencias catastróficas.
  • Con algunas API de gráficos, para obtener esta marca de tiempo, se debe habilitar VSync. Esto significa que si VSync está deshabilitado, el tiempo aún debe calcularse manualmente.

Sin embargo, creo que este enfoque merece el riesgo y el esfuerzo. El resultado obtenido con este método es muy confiable y produce tiempos que corresponden directamente a lo que se ve en la pantalla.

Como ya no tenemos que muestrear el tiempo nosotros mismos, los pasos WaitForLastPresention() y SampleTime() se combinan en un nuevo paso:

while (!ShouldQuit()) 
{ 
  PumpOSMessages(); 
  UpdateInput(); 
  WaitForLastPresentationAndGetTimestamp(); 
  Update(); 
  WaitForRenderThread(); 
  IssueRenderingCommands(); 
}

Con esto se soluciona el problema del movimiento nervioso.

Consideraciones sobre la latencia de entrada

La latencia de entrada es un tema complicado. No es muy fácil medirlo con precisión y puede deberse a varios factores diferentes: hardware de entrada, sistema operativo, controladores, motor del juego, lógica del juego y pantalla. Aquí me centro en el factor del motor del juego de la latencia de entrada, ya que Unity no puede afectar los demás factores.

La latencia de entrada del motor es el tiempo transcurrido entre que el mensaje del sistema operativo de entrada está disponible y el envío de la imagen a la pantalla. Dado el bucle del hilo principal, puede visualizar la latencia de entrada como parte del código (suponiendo que QualitySettings.maxQueuedFrames esté configurado en 2):

PumpOSMessages(); // Pump input OS messages for frame 0
UpdateInput(); // Process input for frame 0
--------------------- // Earliest input event from the OS that didn't become part of frame 0 arrives here!
WaitForLastPresentationAndGetTimestamp(); // Wait for frame -2 to appear on the screen
Update(); // Update game state for frame 0
WaitForRenderThread(); // Wait until all commands from frame -1 are submitted to the GPU
IssueRenderingCommands(); // Send rendering commands for frame 0 to the rendering thread
PumpOSMessages(); // Pump input OS messages for frame 1
UpdateInput(); // Process input for frame 1
WaitForLastPresentationAndGetTimestamp(); // Wait for frame -1 to appear on the screen
Update(); // Update game state for frame 1, finally seeing the input event that arrived
WaitForRenderThread(); // Wait until all commands from frame 0 are submitted to the GPU
IssueRenderingCommands(); // Send rendering commands for frame 1 to the rendering thread
PumpOSMessages(); // Pump input OS messages for frame 2
UpdateInput(); // Process input for frame 2
WaitForLastPresentationAndGetTimestamp(); // Wait for frame 0 to appear on the screen
Update(); // Update game state for frame 2
WaitForRenderThread(); // Wait until all commands from frame 1 are submitted to the GPU
IssueRenderingCommands(); // Send rendering commands for frame 2 to the rendering thread
PumpOSMessages(); // Pump input OS messages for frame 3
UpdateInput(); // Process input for frame 3
WaitForLastPresentationAndGetTimestamp(); // Wait for frame 1 to appear on the screen. This is where the changes from our input event appear.

¡Ufff, eso es todo! Suceden muchas cosas entre el momento en que la entrada está disponible como mensaje del sistema operativo y el momento en que sus resultados son visibles en la pantalla. Si Unity no pierde cuadros y el tiempo empleado en el bucle del juego es en su mayor parte espera en comparación con el procesamiento, el peor escenario de latencia de entrada del motor para una frecuencia de actualización de 144 Hz es 4 * 6,94 = 27,76 ms, porque estamos esperando que los cuadros anteriores aparezcan en la pantalla cuatro veces (eso significa cuatro intervalos de frecuencia de actualización).

Puede mejorar la latencia bombeando eventos del sistema operativo y actualizando la entrada después de esperar a que se muestre el cuadro anterior:

while (!ShouldQuit())
{
  WaitForLastPresentationAndGetTimestamp();
  PumpOSMessages();
  UpdateInput();
  Update();
  WaitForRenderThread();
  IssueRenderingCommands();
}

Esto elimina una espera de la ecuación y ahora la latencia de entrada en el peor de los casos es 3 * 6,94 = 20,82 ms.

Es posible reducir aún más la latencia de entrada reduciendo QualitySettings.maxQueuedFrames a 1 en las plataformas que lo admiten. Entonces, la cadena de procesamiento de entrada se ve así:

--------------------- // Input event arrives from the OS!
WaitForLastPresentationAndGetTimestamp(); // Wait for frame -2 to appear on the screen
PumpOSMessages(); // Pump input OS messages for frame 0
UpdateInput(); // Process input for frame 0
Update(); // Update game state for frame 0 with the input event that we are measuring
WaitForRenderThread(); // Wait until all commands from frame -1 are submitted to the GPU
IssueRenderingCommands(); // Send rendering commands for frame 0 to the rendering thread
WaitForLastPresentationAndGetTimestamp(); // Wait for frame 0 to appear on the screen. This is where the changes from our input event appear.

Ahora, la latencia de entrada en el peor de los casos es 2 * 6,94 = 13,88 ms. Este es el valor más bajo al que podemos llegar cuando usamos VSync.

Advertencia: Establecer QualitySettings.maxQueuedFrames en 1 esencialmente deshabilitará la canalización en el motor, lo que hará que sea mucho más difícil alcanzar la velocidad de cuadros objetivo. Tenga en cuenta que si termina ejecutando a una velocidad de cuadros más baja, su latencia de entrada probablemente será peor que si mantuviera QualitySettings.maxQueuedFrames en 2. Por ejemplo, si hace que baje a 72 cuadros por segundo, su latencia de entrada será 2 * 1⁄72 = 27,8 ms, lo que es peor que la latencia anterior de 20,82 ms. Si desea utilizar esta configuración, le sugerimos que la agregue como una opción al menú de configuración de su juego para que los jugadores con hardware rápido puedan reducir QualitySettings.maxQueuedFrames, mientras que los jugadores con hardware más lento puedan mantener la configuración predeterminada.

Efectos de VSync en la latencia de entrada

Deshabilitar VSync también puede ayudar a reducir la latencia de entrada en determinadas situaciones. Recuerde que la latencia de entrada es la cantidad de tiempo que transcurre entre el momento en que una entrada se vuelve disponible desde el sistema operativo y el momento en que el marco que procesó la entrada se muestra en la pantalla o, como una ecuación matemática:

latencia = tdisplay - tinput

Dada esta ecuación, hay dos maneras de reducir la latencia de entrada: hacer que tdisplay sea más bajo (mostrar la imagen antes) o hacer que tinput sea más alto (consultar eventos de entrada más tarde).

El envío de datos de imágenes desde la GPU a la pantalla consume una gran cantidad de datos. Hagamos los cálculos: para enviar una imagen no HDR de 2560 x 1440 a la pantalla 144 veces por segundo se requieren transmitir 12,7 gigabits por segundo (24 bits por píxel * 2560 * 1440 * 144). Estos datos no se pueden transmitir en un instante: la GPU está transmitiendo constantemente píxeles a la pantalla. Después de transmitir cada fotograma, hay una breve pausa y comienza la transmisión del siguiente fotograma. Este período de descanso se llama VBLANK. Cuando VSync está habilitado, básicamente le estás indicando al sistema operativo que invierta el búfer de cuadros solo durante VBLANK:

Marco

Cuando desactiva VSync, el búfer posterior se cambia al búfer frontal en el momento en que finaliza la renderización, lo que significa que la pantalla de repente comenzará a tomar datos de la nueva imagen en medio de su ciclo de actualización, lo que hace que la parte superior del marco sea del marco más antiguo y la parte inferior del marco sea del marco más nuevo:

Marco

Este fenómeno se conoce como “desgarro”. El desgarro nos permite reducir la visualización en la parte inferior del cuadro, sacrificando la calidad visual y la suavidad de la animación a cambio de la latencia de entrada. Esto es especialmente efectivo cuando la velocidad de cuadros del juego es menor que el intervalo VSync, lo que permite una recuperación parcial de la latencia causada por un VSync faltante. También es más efectivo en juegos donde la parte superior de la pantalla está ocupada por la interfaz de usuario o un skybox, lo que hace que sea más difícil notar el desgarro.

Otra forma en que deshabilitar VSync puede ayudar a reducir la latencia de entrada es aumentando tinput. Si el juego es capaz de renderizarse a una velocidad de cuadros mucho más alta que la frecuencia de actualización (por ejemplo, a 150 fps en una pantalla de 60 Hz), entonces deshabilitar VSync hará que el juego bombee eventos del SO varias veces durante cada intervalo de actualización, lo que reducirá el tiempo promedio que permanecen en la cola de entrada del SO esperando que el motor los procese.

Tenga en cuenta que la desactivación de VSync en última instancia debe ser una decisión del jugador de su juego, ya que afecta la calidad visual y puede causar náuseas si el desgarro termina siendo notorio. Se recomienda proporcionar una opción de configuración en el juego para habilitarla o deshabilitarla si la plataforma la admite.

Conclusión

Con esta corrección implementada, la línea de tiempo de cuadros de Unity se ve así:

Marco

Pero ¿realmente mejora la suavidad del movimiento de los objetos? ¡Puedes apostar que sí!

Ejecutamos la demostración de Unity 2020.1 que mostramos al comienzo de esta publicación en Unity 2020.2.0b1. Aquí está el vídeo en cámara lenta resultante:

Esta solución está disponible en la versión beta 2020.2 para estas plataformas y API de gráficos:

  • Windows, Xbox One, Plataforma universal de Windows (D3D11 y D3D12)
  • macOS, iOS, tvOS (Metal)
  • Playstation 4
  • Switch

Planeamos implementar esto para el resto de nuestras plataformas compatibles en el futuro cercano.

Siga este hilo del foro para obtener actualizaciones y cuéntenos qué piensa sobre nuestro trabajo hasta ahora.

Lectura adicional sobre la sincronización de cuadros
Versión beta de Unity 2020.2 y posteriores
Descripción general de la versión beta

Si está interesado en obtener más información sobre lo que está disponible en 2020.2, consulte la publicación del blog beta y regístrese para el seminario web beta de Unity 2020.2. También compartimos recientemente nuestros planes de hoja de ruta para 2021.