lunes, 5 de octubre de 2015

El control del tiempo en MotoGT2 (parte 2)

Hace unos días hablaba de qué es esto del "framerate" en un juego, cómo se puede relacionar con la física del mismo, y cuál era la solución simple que había usado en MotoGT 1. Si ya entendieron eso, ahora toca mostrar la solución un poco más complicada que estoy usando en MotoGT 2.

Pues bien, la primera implementación se basó en lo que vi en un video tutorial de Vitorio Romero. Él propone un sistema realmente muy simple. Y por simple no digo "estúpido" sino "elegante". Resumiendo, fijar un framerate constante para la física, e implementar un truco inteligente para renderizar con un framerate variable el estado que diga la física al momento del rendering. Digamos que la física irá más rápido, y el rendering irá tan rápido como pueda, pero sin la obligación de renderizar todos los "frames de la física". El loop principal del juego quedaría más o menos así:
  unsigned int tiempo_acumulado = 0;
  auto t0 =  high_resolution_clock::now();

  const unsigned int dt_fisica = 1000/60; // 60FPS 
  while ( true ) {
      // rendering
  
    m_current_scene->Render();
   
  // control de tiempos
      auto t1 = high_resolution_clock::now();
  
    tiempo_acumulado += duration_cast<milliseconds>(t1-t0).count();
      t0 = t1;
  
    // actualización de la física
      while (dt_fisica<tiempo_acumulado) {
  
        m_current_scene->UpdateLogic();
          tiempo_acumulado -= dt_fisica;
  
    }
   }
La idea es que en cada iteración del loop principal se ejecuta un render y varias actualizaciones de física. Tantas como sea necesario para que el tiempo de la física cubra el tiempo que tomó el rendering. Es decir, dibujo, mido cuanto tardé en dibujar, y aplico tantos pasos de física como sea necesario para cubrir ese período. Los pasos son siempre constantes. Entonces la física es simple, y puedo definirla con un framerate suficientemente alto para evitar problemas como el de las colisiones que contaba en el post anterior, pero permitiendo al rendering ir más lento si quiere.

Física (azul) a frecuencia constante, render (verde) a igual o menor frecuencia.

Esto en muchos escenarios funciona muy bien, ya que es común que el rendering sea una tarea mucho más pesada y demandante que la física. Entonces, es esperable que la parte de la física no tenga problemas para mantener el ritmo, y la parte del rendering ya no tiene un mínimo al que ajustarse. Se le puede agregar alguna leve complicación para evitar que el framerate caiga por debajo de un mínimo, o detalles así, pero en general no se requiere mucho más que lo que puse en el ejemplo.

Hay un solo "pero". Digamos que tenemos un PC muy rápida, o un juego muy simple. Por ejemplo, en mi notebook (Core I5 de 3ra gen. con video Intel), las primeras pruebas del nuevo motor me daban alrededore de 1000 FPS. Pero esto es un desperdicio por varios motivos. Ignoremos por ahora la taza de refresco del monitor (que impone un límite de "hardware" que no podremos superar). Si la física va a, por ejemplo 60FPS, dibujar a 1000FPS no tiene sentido, ya que en esos 1000 solo va a haber 60 estados diferentes. Más o menos, el 94% de los redibujados son inútiles. Pero si pongo a la física a funcionar a 1000FPS para que aprovechar la potencia de rendering, ahora la física será bastante demadante y en una PC más vieja se convertirá en un problema. Por esto, si bien empecé programando mi nuevo engine para usar esta estrategia, plantié las actualizaciones de física genéricas para cualquier salto de tiempo, de forma de poder ajustar más tarde ese 30 o 60.


Pero lo "más mejor" sería que aunque la física tenga un límite, pueda si sobra poder de cómputo, trabajar con más definición. El límite podrían ser esos 30 o 60 FPS, lo suficiente para que la detección de colisiones no haga cosas raras. Pero de ahí para arriba, si sobra tiempo, que lo mejore. Además, si un monitor trabaja a una frecuencia cercana a 60Hz, pero no igual, el "muestreo" temporal haría un efecto raro. Por ejemplo, si fuera un monitor a 70Hz y la física a 60Hz, uno de cada 7 frames se repetiría, y la velocidad del movimiento sería constante solo en 6 de cada 7 frames. Entonces, para evitar todos esos problemas, podemos mejorar el bucle anterior como sigue:
  unsigned int tiempo_acumulado = 0;
  auto t0 =  high_resolution_clock::now();
  const unsigned int max_dt_fisica = 1000/60;
  while ( true ) {
      // rendering
      m_current_scene->Render();
      // control de tiempos
      auto t1 = high_resolution_clock::now();
      tiempo_acumulado += duration_cast<milliseconds>(t1-t0).count();
      t0 = t1;
      // actualización de la física
      if (max_dt_fisica<tiempo_acumulado) { // si el render tarda...
          while (max_dt_fisica<tiempo_acumulado) {
              m_current_scene->UpdateLogic(max_dt_fisica);
              tiempo_acumulado -= max_dt_fisica;
          }
      } else { // si sobra tiempo...
          m_current_scene->UpdateLogic(tiempo_acumulado);
          tiempo_acumulado = 0;
      }
  }

Ahora, si el render lleva menos de 1/60, la física se actualiza con saltos más pequeños (lo que sea que toma el render) para seguirle el ritmo. Y la implementación final en MotoGT 2 tiene esta última estructura, pero agregando algunas capas más de porquerías del engine, con un if adicional para evitar que el framerate caiga demasiado si la PC es muuuuy lenta. Y con un detalle más: la medición de saltos de tiempos en microsegundos en lugar de milisegundos. Al estar en el orden de los cientos de FPS, millisegundos puede no ser resolución temporal suficiente, y esto puede hacer que correr el juego a distintos framerates genere resultados levemente diferentes (cosa que verifiqué corriendo dos instancias a la par a diferentes framerates).

Física a frecuencia variable entre 1KHz (max) y 30Hz (min), render libre.

Estos pequeños errores, acumulados, podrían llevar a que el jugador pueda, por ejemplo, bajar su tiempo de vuelta unas décimas simplemente cambiando la configuración de los gráficas (para alterar el framerate) o cambiando de PC. Recuerdo que esto pasaba por ejemplo en el primer Moto Racer (gran juego). De igual forma, si calculo posiciones, aceleraciones, etc, con precisión de microsegundos, un float podría no ser suficiente para captar pequeñas variaciones. Por eso, en la implementación final el bucle principal mide los tiempos a nivel de microsegundos, pero manda a actualizar la física con saltos de millisegundos. Con todo esto, en mi máquina, la nueva implementación me corre a alrededor de 700 FPS (ahora cada frame implica tanto un paso de rendering como un paso de física). Igual es un desperdicio, porque la taza de refresco de mi monitor es de 60 FPS. Por esto, si total aunque calculemos más rápido, el monitor no lo va a mostrar, lo mejor es poder definir también un tope para los FPS, o usar la famosa "sincronización vertical" (vsync) como referencia y evitar así freir al pobre procesador inútilmente.

En conclusión, el nuevo engine es más flexible, y la diferencia en la fluidez de los movimientos realmente se percibe, más de lo que yo esperaba. Si uno siempre jugó a 30 fps, como con MotoGT 1, le parece que está bien; pero cuando prueba jugar a 60 descubre que puede estar mucho mejor, y ya no quiere volver a bajar.

Hace uno días borré de SourceForge el repositorio git de MotoGT 1 (quedó una copia de recuerdo como tgz para descarga), y subí lo que tengo hasta ahora de código de MotoGT 2. Todavía no se puede probar, porque faltan los assets (gráficos, sonidos, configuraciones de motos y circutos, etc). Los agregaré cuando empiece a estar jugable y revise el tema de licencias de las poquitas cosas de terceros que uso (como la tipografía y algunos sonidos). Pero mientras se puede ir viendo la verdadera implementación de todas las ideas que discuto en estas páginas. Sigue en desarrollo, así que todo puede cambiar de un día para el otro, pero creo que hasta el momento es (mayormente) un código muy prolijo y documentado.

No hay comentarios:

Publicar un comentario