Imagina el problema: tienes un juego y necesitas que funcione a 60 fps en un monitor de 60 Hz. Su computadora es lo suficientemente rápida para renderizar y actualizar y ocupar una cantidad de tiempo insignificante, por lo que enciende vsync y escribe este ciclo de juego:
while(running) { update(); render(); display(); }
Muy facil! Ahora el juego funciona a 60 fps y todo funciona como un reloj. Listo Gracias por leer esta publicación.
Bueno, obviamente, no todo es tan bueno. ¿Qué pasa si alguien tiene una computadora débil que no puede procesar el juego a una velocidad suficiente para proporcionar 60 fps? ¿Qué pasa si alguien compra uno de esos nuevos monitores de 144 hertz? ¿Qué pasa si apaga vsync en la configuración del controlador?
Podría pensar: necesito medir el tiempo en algún lugar y proporcionar una actualización con la frecuencia correcta. Esto es bastante simple: solo acumule tiempo en cada ciclo y actualice cada vez que exceda el umbral en 1/60 de segundo.
while(running) { deltaTime = CurrentTime()-OldTime; oldTime = CurrentTime(); accumulator += deltaTime; while(accumulator > 1.0/60.0){ update(); accumulator -= 1.0/60.0; } render(); display(); }
Hecho, en ningún lado más fácil. De hecho, hay un montón de juegos en los que el código se ve esencialmente de esa manera. Pero esto está mal. Esto es adecuado para ajustar los tiempos, pero conduce a problemas con sacudidas (tartamudeo) y otros desajustes. Tal problema es muy común: los cuadros no se muestran exactamente 1/60 de segundo; incluso cuando vsync está activado, siempre hay un poco de ruido en el tiempo que se muestran (y en la precisión del temporizador del sistema operativo). Por lo tanto, surgirán situaciones cuando renderice un marco, y el juego cree que el tiempo para la actualización aún no ha llegado (porque la batería está rezagada una pequeña fracción), por lo que simplemente repite el mismo marco nuevamente, pero ahora el juego llega tarde para el marco, por lo que se duplica actualización ¡Aquí está la contracción!
En Google, puede encontrar varias soluciones listas para eliminar esta contracción. Por ejemplo, un juego puede usar una variable en lugar de un paso de tiempo constante, y simplemente abandonar por completo las baterías en el código de tiempo. O puede implementar un paso de tiempo constante con un renderizador interpolador, descrito en un artículo bastante famoso "
Fix Your Timestep " de Glenn Fielder. O bien, puede rehacer el código del temporizador para que sea un poco más flexible, como se describe en la publicación
Problemas de sincronización del
marco de Slick Entertainment (desafortunadamente este blog ya no está allí).
Tiempos difusos
El método Slick Entertainment con "tiempos difusos" en mi motor fue el más fácil de implementar, ya que no requería cambios en la lógica y el renderizado del juego. Así que en
The End is Nigh lo usé. Bastaba con insertarlo en el motor. De hecho, simplemente permite que el juego se actualice "un poco antes" para evitar problemas con los desajustes de tiempo. Si el juego incluye vsync, entonces solo te permite usar vsync como el temporizador principal del juego y proporciona una imagen fluida.
Así es como se ve el código de actualización ahora (el juego "puede funcionar" a 62 fps, pero aún procesa cada paso como si funcionara a 60 fps. No entiendo bien por qué limitarlo para que los valores de la batería no caigan por debajo de 0, pero sin Este código no funciona). Puede interpretarlo de esta manera: "el juego se actualiza con un paso fijo, si se procesa en el intervalo de 60 fps a 62 fps":
while(accumulator > 1.0/62.0){ update(); accumulator -= 1.0/60.0; if(accumulator < 0) accumulator = 0; }
Si vsync está habilitado, esencialmente permite que el juego funcione con un tono fijo, que coincide con la frecuencia de actualización del monitor, y proporciona una imagen uniforme. El principal problema aquí es que cuando vsync está desactivado, el juego funcionará un
poco más rápido, pero la diferencia es tan insignificante que nadie lo notará.
Corredores de velocidad. Los corredores de velocidad lo notarán. Poco después de que se lanzó el juego, notaron que algunas personas en las listas de puntajes más altos de speedran tenían tiempos de viaje más pobres, pero resultó ser mejor que otros. Y la razón inmediata de esto fueron los tiempos poco claros y la desconexión de vsync en el juego (o monitores de 144 Hz). Por lo tanto, se hizo evidente que debe desactivar esta confusión al desconectar vsync.
Ah, pero aún no podemos comprobar si vsync está deshabilitado. No hay llamadas para esto en el sistema operativo, y aunque podemos solicitar a la aplicación que habilite o deshabilite vsync, de hecho, depende completamente del sistema operativo y del controlador de gráficos. Lo único que se puede hacer es renderizar un montón de cuadros, intentar medir el tiempo de ejecución de esta tarea y luego comparar si toman aproximadamente el mismo tiempo. Eso es exactamente lo que hice para
The End is Nigh . Si el juego no incluye vsync con una frecuencia de 60 Hz, entonces regresa al temporizador de cuadro original con "60 fps estrictos". Además, agregué un parámetro al archivo de configuración que obliga al juego a no usar borrosidad (principalmente para los corredores de velocidad que necesitan un tiempo preciso) y agregué un controlador de temporizador exacto en el juego para ellos, lo que permite usar el divisor automático (este es un script que funciona con un temporizador de tiempo atómico).
Algunos usuarios todavía se quejaron de la sacudida ocasional de marcos individuales, pero parecían tan raros que podrían explicarse por eventos del sistema operativo u otras razones externas. No es gran cosa. Derecho?
Al revisar mi código de temporizador recientemente, noté algo extraño. La batería se desplazó, cada cuadro tardó un poco más de 1/60 de segundo, por lo que de vez en cuando el juego pensó que era tarde para el cuadro, y realizó una doble actualización. Resultó que mi monitor funciona con una frecuencia de 59,94 Hz, y no 60 Hz. Esto significaba que cada 1000 cuadros tenía que realizar una doble actualización para "ponerse al día". Sin embargo, esto es muy sencillo de solucionar: simplemente cambie el intervalo de frecuencias de trama permitidas (no de 60 a 62, sino de 59 a 61).
while(accumulator > 1.0/61.0){ update(); accumulator -= 1.0/59.0; if(accumulator < 0) accumulator = 0; }
El problema descrito anteriormente con vsync desconectado y monitores de alta frecuencia aún persiste, y se aplica la misma solución (revertir al temporizador estricto si el monitor
no está sincronizado vsync en 60).
Pero, ¿cómo saber si esta es la solución correcta? ¿Cómo asegurarse de que funcionará correctamente en todas las combinaciones de computadoras con diferentes tipos de monitores, con y sin vsync activado, y así sucesivamente? Es muy difícil hacer un seguimiento de todos estos problemas del temporizador en la cabeza y comprender qué causa la desincronización, los bucles extraños y demás.
Monitor simulador
Al intentar encontrar una solución confiable para el "problema del monitor de 59.94 hertzios", me di cuenta de que no podía simplemente realizar comprobaciones de prueba y error, con la esperanza de encontrar una solución confiable. Necesitaba una manera conveniente de probar diferentes intentos de escribir un temporizador de alta calidad y una manera fácil de verificar si causa una sacudida o un cambio de tiempo en diferentes configuraciones de monitor.
Monitor Simulator aparece en la escena. Este es el código "sucio y rápido" que escribí, simulando la "operación del monitor" y esencialmente mostrándome un montón de números que dan una idea de la estabilidad de cada temporizador probado.
Por ejemplo, para el temporizador más simple, los siguientes valores se muestran desde el principio del artículo:
20211012021011202111020211102012012102012[...]
TOTAL UPDATES: 10001
TOTAL VSYNCS: 10002
TOTAL DOUBLE UPDATES: 2535
TOTAL SKIPPED RENDERS: 0
GAME TIME: 166.683
SYSTEM TIME: 166.7
Primero, el código muestra para cada vsync emulado el número del número de "actualizaciones" al ciclo del juego después del vsync anterior. Cualquier valor que no sea sólido 1 conduce a una imagen nerviosa. Al final, el código muestra las estadísticas acumuladas.
Cuando se utiliza el "temporizador difuso" (con un intervalo de 60–62 fps) en un monitor de 59,94 hercios, el código muestra lo siguiente:
111111111111111111111111111111111111111111111[...]
TOTAL UPDATES: 10000
TOTAL VSYNCS: 9991
TOTAL DOUBLE UPDATES: 10
TOTAL SKIPPED RENDERS: 0
GAME TIME: 166.667
SYSTEM TIME: 166.683
La sacudida del marco es muy rara, por lo que puede ser difícil notarlo con un número de 1. Pero las estadísticas mostradas muestran claramente que el juego ha realizado varias actualizaciones dobles aquí, lo que lleva a la sacudida. En la versión fija (con un intervalo de 59–61 fps), hay 0 actualizaciones omitidas o dobles.
También puede deshabilitar vsync. El resto de los datos estadísticos deja de ser importante, pero esto me muestra claramente la magnitud del "cambio de tiempo" (el cambio de tiempo del sistema en relación con el tiempo del juego).
GAME TIME: 166.667
SYSTEM TIME: 169.102
Es por eso que cuando vsync está deshabilitado, debe cambiar a un temporizador estricto, de lo contrario, estas discrepancias se acumulan con el tiempo.
Si configuro el tiempo de representación en .02 (es decir, se necesita "más de un cuadro" para la representación), obtendré una contracción. Idealmente, el patrón del juego debería verse como 202020202020, pero es un poco desigual.
En esta situación, este temporizador se comporta un poco mejor que el anterior, pero se vuelve más confuso y más difícil descubrir cómo y por qué funciona. Pero solo puedo poner las pruebas en este simulador y verificar cómo se comportan, y puedes averiguar las razones más adelante. Prueba y error, bebé!
while(accumulator >= 1.0/61.0){ simulate_update(); accumulator -= 1.0/60.0; if(accumulator < 1.0/59.0–1.0/60.0) accumulator = 0; }
Puede descargar
un simulador de monitor y verificar de forma independiente diferentes métodos de cálculo de tiempos.
Envíame un correo electrónico si encuentras algo mejor.
No estoy 100% satisfecho con mi decisión (todavía requiere un truco con "reconocimiento de vsync" y pueden ocurrir sacudidas ocasionales durante la desincronización), pero creo que es casi tan bueno como un intento de implementar un ciclo de juego con un paso fijo. Parte de este problema surge porque aquí es muy difícil determinar los parámetros de lo que se considera "aceptable". La principal dificultad radica en la compensación entre el cambio de tiempo y los marcos dobles / omitidos. Si ejecuta un juego de 60 Hz en un monitor PAL de 50 Hz ... ¿cuál será la decisión correcta? ¿Quieres sacudidas salvajes o un juego notablemente más lento? Ambas opciones parecen malas.
Renderizado separado
En los métodos anteriores, describí lo que yo llamo "representación en bloque". El juego actualiza su estado, luego renderiza y, cuando se procesa, siempre muestra el estado más reciente del juego. El renderizado y la actualización están conectados entre sí.
Pero puedes separarlos. Esto es exactamente lo que hace el método descrito en la
publicación "
Fix Your Timestep ". No me repetiré, definitivamente deberías leer esta publicación. Este (según tengo entendido) es el "estándar de la industria" utilizado en los juegos y motores AAA como Unity e Unreal (sin embargo, en los juegos activos 2D intensos, generalmente prefieren usar un paso fijo (bloqueo), porque a veces la precisión que le brinda este método)
Pero si describimos brevemente la publicación de Glenn, simplemente describe el método de actualización con una velocidad de cuadro fija, pero cuando se procesa, la interpolación se realiza entre el estado "actual" y "anterior" del juego, y el valor actual de la batería se usa como el valor de interpolación. Con este método, puede renderizar a cualquier velocidad de cuadros y actualizar el juego a cualquier frecuencia, y la imagen siempre será suave. Sin sacudidas, funciona universalmente.
while(running){ computeDeltaTimeSomehow(); accumulator += deltaTime; while(accumulator >= 1.0/60.0){ previous_state = current_state; current_state = update(); accumulator -= 1.0/60.0; } render_interpolated_somehow(previous_state, current_state, accumulator/(1.0/60.0)); display(); }
Entonces, elemental. El problema está resuelto.
Ahora solo necesitas asegurarte de que el juego pueda representar los estados interpolados ... pero espera un minuto, realmente no es nada fácil. En la publicación de Glenn, simplemente se supone que esto se puede hacer. Es bastante fácil almacenar en caché la posición anterior del objeto del juego e interpolar sus movimientos, pero el estado del juego es mucho más que eso. Es necesario tener en cuenta en él el estado de la animación, la creación y destrucción de objetos y un montón de cosas.
Además, en la lógica del juego, debe considerar si el objeto se teletransporta o si se debe mover suavemente para que el interpolador no haga suposiciones falsas sobre la ruta realizada por el objeto del juego a su posición actual. El caos real puede ocurrir con los giros, especialmente si en un cuadro el giro de un objeto puede cambiar en más de 180 grados. ¿Y cómo procesar adecuadamente los objetos creados y destruidos?
Por el momento, solo estoy trabajando en esta tarea en mi motor. De hecho, solo interpolo los movimientos y dejo todo lo demás como está. No notará sacudidas si el objeto no se mueve suavemente, por lo tanto, omitir cuadros de animación y sincronizar la creación / destrucción del objeto en un cuadro no se convertirá en un problema si todo lo demás se realiza sin problemas.
Sin embargo, es extraño que, de hecho, este método represente el juego en un estado que está retrasado por 1 estado del juego desde donde ahora se encuentra la simulación. Esto es discreto, pero se puede conectar a otras fuentes de retrasos, por ejemplo, el retraso de entrada y la frecuencia de actualización del monitor, por lo que aquellos que necesitan el juego más receptivo (estoy hablando de ustedes, corredores de velocidad) probablemente preferirán usar el bloqueo en el juego.
En mi motor, solo doy una opción. Si tiene un monitor de 60 hertzios y una computadora rápida, lo mejor es usar lockstep con vsync habilitado. Si el monitor tiene una frecuencia de actualización no estándar, o su computadora débil no puede procesar constantemente 60 cuadros por segundo, entonces habilite la interpolación de cuadros. Quiero llamar a esta opción "desbloquear framerate", pero la gente podría pensar que simplemente significa "habilitar esta opción si tiene una buena computadora". Sin embargo, este problema puede resolverse más tarde.
En realidad, hay
un método para solucionar este problema.
Actualizaciones de paso de tiempo variable
Muchas personas me preguntaron por qué no solo actualizan el juego con un paso de tiempo variable, y los programadores teóricos a menudo dicen: "si el juego está escrito CORRECTAMENTE, entonces simplemente puede actualizarlo con un paso de tiempo arbitrario".
while(running) { deltaTime = CurrentTime()-OldTime; oldTime = CurrentTime(); update(deltaTime); render(); display(); }
No hay rarezas con los tiempos. Sin renderizado de interpolación extraño. Todo es simple, todo funciona.
Entonces, elemental. El problema está resuelto. Y ahora para siempre! ¡Es imposible lograr un mejor resultado!
Ahora es lo suficientemente simple como para que la lógica del juego funcione con un paso de tiempo arbitrario. Es simple, simplemente reemplace todo este código:
position += speed;
en esto:
position += speed * deltaTime;
y reemplace el siguiente código:
speed += acceleration; position += speed;
en esto:
speed += acceleration * deltaTime; position += speed * deltaTime;
y reemplace el siguiente código:
speed += acceleration; speed *= friction; position += speed;
en esto:
Vec3D p0 = position; Vec3D v0 = velocity; Vec3D a = acceleration*(1.0/60.0); double f = friction; double n = dt*60; double fN = pow(friction, n); position = p0 + ((f*(a*(f*fN-f*(n+1)+n)+(f-1)*v0*(fN-1)))/((f-1)*(f-1)))*(1.0/60.0); velocity = v0*fN+a*(f*(fN-1)/(f-1));
... entonces espera
¿De dónde vino todo esto?
La última parte se copia literalmente del código auxiliar de mi motor, que realiza "un movimiento realmente correcto, independiente de la velocidad de fotogramas con velocidad de limitación de fricción". Hay un poco de basura en él (estas multiplicaciones y divisiones por 60). Pero esta es la versión "correcta" del código con un paso de tiempo variable para el fragmento anterior. Lo descubrí durante más de una hora con
Wolfram Alpha .
Ahora pueden preguntarme por qué no hacerlo así:
speed += acceleration * deltaTime; speed *= pow(friction, deltaTime); position += speed * deltaTime;
Y aunque parece funcionar, en realidad está mal hacerlo. Puedes comprobarlo tú mismo. Realice dos actualizaciones con deltaTime = 1, y luego realice una actualización con deltaTime = 2, y los resultados serán diferentes. Por lo general, nos esforzamos para que el juego funcione en concierto, por lo que esas discrepancias no son bienvenidas. Probablemente sea una solución lo suficientemente buena, si sabe con certeza que deltaTime siempre es aproximadamente igual a un valor, pero luego necesita escribir código para asegurarse de que las actualizaciones se realicen con cierta frecuencia constante y ... sí. Así es, ahora estamos tratando de hacer todo "CORRECTAMENTE".
Si un código tan pequeño se despliega en monstruosos cálculos matemáticos, imagínese patrones de movimiento más complejos en los que participen muchos objetos interactivos, y cosas por el estilo. Ahora puede ver claramente que la solución "correcta" es irrealizable. Lo máximo que podemos lograr es una "aproximación aproximada". Olvidémoslo por ahora y supongamos que realmente tenemos una versión "realmente correcta" de las funciones de movimiento. Genial, verdad?
No, en realidad Aquí hay un ejemplo de la vida real del problema que tuve con esto en
Bombernauts . Un jugador puede rebotar aproximadamente 1 mosaico, y el juego se desarrolla en una cuadrícula de bloques en 1 mosaico. Para aterrizar en un bloque, las piernas del personaje deben elevarse por encima de la superficie superior del bloque.
Pero dado que el reconocimiento de colisiones aquí se realiza con un paso discreto, si el juego funciona con una velocidad de fotogramas baja, a veces las patas no alcanzarán la superficie de la loseta, aunque siguieron la misma curva de movimiento, y en lugar de levantar, el jugador se deslizará fuera de la pared.
Obviamente, este problema es solucionable. Pero ilustra los tipos de problemas que encontramos cuando intentamos implementar correctamente el trabajo del ciclo del juego con un paso de tiempo variable. Perdemos coherencia y determinismo, por lo que tendremos que deshacernos de las funciones de repetición del juego registrando la entrada del jugador, el multijugador determinista y demás. Para los juegos 2D rápidos basados en reflejos, la consistencia es extremadamente importante (y hola nuevamente a los corredores de velocidad).
Si intenta ajustar los pasos de tiempo para que no sean ni demasiado grandes ni demasiado pequeños, perderá la ventaja principal obtenida del paso de tiempo variable, y puede usar con seguridad los otros dos métodos descritos aquí. El juego no vale la pena. Se pondrá demasiado esfuerzo adicional en la lógica del juego (la implementación de las matemáticas correctas del movimiento), y se requerirán demasiadas víctimas en el campo del determinismo y la coherencia. Usaría este método solo para un juego de ritmo musical (en el que las ecuaciones de movimiento son simples y requieren la máxima capacidad de respuesta y suavidad). En todos los demás casos, elegiré una actualización fija.
Conclusión
Ahora sabes cómo hacer que el juego funcione a una frecuencia constante de 60 fps. Esto es trivialmente simple, y nadie más debería tener un problema con él. No
hay otros problemas que complican esta tarea.