Programación de videojuegos - Game Loop

El Game Loop es uno de los patrones más importantes dentro de la programación de videojuegos, se trata del bucle principal desde donde se desarrolla toda la actividad del juego llamando a los diferentes subsistemas.

La función principal del bucle es ejecutar los diferentes componentes del juego por pasos:

  • procesar eventos (dispositivos I/O)
  • ejecutar lógica del juego (AI)
  • ejecutar físicas del juego
  • audio
  • sincronización en red para juegos online
  • animación
  • renderizar

Cada uno de estos subsistemas necesitan ejecutarse en ratios que varían de unos a otros. La animación normalmente necesita actualizarse a ratios de 30 a 60 Hz en sincronización con el sistema de renderizado. Las físicas del juego necesitan un sistema que permita mantener su lógica en equipos que procesen a diferentes velocidades.

Todos estos subsistemas podemos englobarlos en grupos de tareas que son: Comprobar entradas de usuario - Actualizar lógica del juego - Dibujar resultado:

  • Comprobamos las entradas de usuario que se han producido desde la última llamada a este método. Esta comprobación se realiza sin bloquear la aplicación. Es decir, al contrario que una aplicación convencional, por ejemplo un procesador de texto en la que la aplicación espera la entrada del usuario para realizar acciones, en un juego no se bloquea la aplicación, esta tiene que seguir funcionando aunque el usuario no realice ninguna entrada.
  • En actualizar la lógica del juego se calcula los movimientos que se tienen que producir en nuestro juego. Esta lógica es propia del juego por tanto en cada caso será diferente. Teniendo en cuenta las entradas del usuario se calculan los movimientos a nuestro personaje. Para esto se tienen en cuenta las físicas que se aplican al juego. Por ejemplo, la gravedad que aplicamos a un personaje que caiga por un barranco, el calculo de las colisiones con otros objetos, otras fuerzas que queramos aplicar, fricciones, etc. En AI (inteligencia artificial) se calcula los movimientos de los personajes que funcionan de forma autónoma e interactúan con el usuario y por tanto dependiendo de las acciones que tome el usuario esta actuará en consecuencia. Con estos datos se calculan los movimientos de los objetos en pantalla para pasar al siguiente proceso.
  • En la animación y renderizado se dibujan los objetos con los cambios realizados en la fase anterior. Esto se realiza utilizando un patrón conocido como doble buffer. Los elementos se van dibujando en un buffer que no se muestra en pantalla. Cuando todos los objetos se han dibujado se muestra el buffer. Esto permite ver un movimiento fluido y la sensación de movimiento sin parpadeos. SDL realiza este trabajo por nosotros, por lo que no tenemos que preocuparnos por manejar el buffer.

Frame Rate y Time Deltas

Si tenemos estos procesos en nuestro bucle (Game Loop) ¿como sabemos cuantas veces se ejecutan en cada segundo?

El frame rate (velocidad de fotogramas) del juego describe la rapidez con la que se presenta al espectador la secuencia de fotogramas estáticos. La unidad de Hertz (Hz), definida como el número de ciclos por segundo, se puede utilizar para describir la velocidad de cualquier proceso periódico. En juegos y películas, la velocidad de fotogramas se mide normalmente en fotogramas por segundo (FPS - frames per second), que es lo mismo que Hertz para todos los efectos (Game Engine Architecture - Jason Gregory).

La cantidad de tiempo que transcurre entre fotogramas se conoce como frame time, time delta o delta time. Si un juego se renderiza a 30 FPS, entonces su delta time es 1/30 de segundo, o 33,3 ms (milisegundos). A 60 FPS, el delta time sería la mitad, es decir, 1/60 de segundo o 16,6 ms.

¿Cómo sabemos entonces a que FPS se ejecutará nuestro juego?

Cada uno de los procesos, comprobar entradas de usuario, actualizar lógica del juego y renderizar requiere un tiempo de proceso de la CPU. Este tiempo de proceso depende de la velocidad de la máquina en la que se ejecute. Cuando se programa para una plataforma específica, por ejemplo un tipo de consola concreta, el tiempo que se utiliza en cada proceso será siempre el mismo para todas las consolas, ya que todas son iguales. Cuando se programa para PC o para diferentes plataformas los tiempos de CPU son siempre variables.

Por tanto tenemos dos variables a la hora de saber cuantas veces se ejecuta nuestro bucle por segundo: La cantidad de cálculos que se realicen en nuestros procesos (comprobar eventos - cálculos de lógica - renderizar) y la potencia del equipo en el que se ejecutan.

Tipos de GameLoop

Para poder recrear una animación a una velocidad constante, independiente de estas dos variables, necesitamos un método que calcule el tiempo que tarda en procesar la CPU los diferentes procesos y ajuste un tiempo de espera en cada ciclo. Esta es por tanto la otra función principal de nuestro Game Loop, ejecutar el juego a una velocidad consistente independientemente de la CPU en la que se ejecute y la cantidad de cálculos que se produzcan en los métodos que se ejecutan el en bucle.

Vamos a ver dos tipos de Game Loop que nos van a valer en la mayoría de los casos. Por supuesto existen versiones mucho más complejas y elaboradas (ver por ejemplo el Game Loop del engine Unity).

Paso de tiempo fijo con sincronización

En el ejemplo que vimos en el post anterior añadimos un retardo constante al final de nuestro game loop, pero como acabamos de comentar este método solo es válido si ejecutamos la aplicación siempre en el mismo hardware y no varían los cálculos de la lógica de nuestra aplicación. Lo que necesitamos entonces es comprobar cuanto tardan en ejecutarse nuestros diferentes métodos y añadir el retardo teniendo en cuenta este tiempo de proceso:

Paso de tiempo fijo con sincronización

De esta forma, comprobamos el tiempo antes de ejecutar los métodos, ejecutamos nuestro comprobador de eventos, hacemos los cálculos de la lógica de nuestro juego y renderizamos. Al finalizar todo esto comprobamos el tiempo transcurrido (tomamos el tiempo actual y le restamos el tiempo inicial) y añadimos un retardo a nuestro bucle con el resultado.

En Vala el código sería algo como esto:

while (!input.done) {  
    uint32 last_time = SDL.Timer.get_ticks (); 
 
    wsdl.prepare_scene (); 
    input.process_events (); 
    update (); 
    draw (); 
    wsdl.present_scene (); 
      
    uint32 frame_time = SDL.Timer.get_ticks () - last_time; 
    float wait = MS_PER_FRAME - frame_time; 
 
    if (wait < 1) 
        wait = 1; 
 
    SDL.Timer.delay ((uint32) wait); 
} 

Tomamos el tiempo antes de procesar en last_time. Ejecutamos los métodos y calculamos el tiempo que han tomado los métodos en procesar (volviendo a tomar el tiempo y restando el anterior). Esto lo guardamos en frame_time.

En la constante MS_PER_FRAME tenemos los milisegundos que queremos que tome nuestro bucle. Como vimos antes, si queremos un FPS de 60 necesitamos un frame time o delta time de 1/60 de segundo, es decir 16.6 milisegundos. A este valor le restamos el frame_time obtenido y ya tenemos el tiempo de espera que necesitamos en la variable wait.

Antes de pasarlo a la función SDL.Timer.delay () comprobamos que wait no sea negativo. Esto indicaría que nuestros procesos toman demasiado tiempo para el hardware de la máquina y no sería capaz de procesar a esos FPS. De cualquier forma si pasamos un valor negativo a la función SDL.Timer.delay () que solo acepta valores sin signo tendríamos un overflow por signo que, en el mejor de los casos, nos congelaría el bucle durante un buen rato.

Una cosa a tener en cuenta es que el valor que le pasamos a SDL.Timer.delay () es un entero y la parte decimal no se tienen en cuenta. Por tanto aunque usemos 16.66667 en MS_PER_FRAME, cuando lo pasamos a wait que es un uint32 se quedaría en 16. Esta diferencia hace que al final de un segundo nuestros FPS sean cercanos a 62, en vez de a 60. Con el fin de tener mayor precisión se puede utilizar una variable para ir acumulando la parte decimal e ir añadiéndola a wait en cada ciclo. 

Tendíamos entonces el siguiente Game Loop:

public void run () { 
    wsdl.init_loop (); 
 
    while (!input.done) { 
        wsdl.prepare_scene (); 
        input.process_events (); 
        update (); 
        draw (); 
        wsdl.present_scene (); 
        wsdl.cap_frame_rate (); 
    } 
} 

Que llama  a los siguientes métodos para realizar los cálculos y aplicar la espera:

private const int WAIT = 1000 / FPS; 
private const float REST = (1000.0f / FPS) - WAIT; 
 
public void init_loop () { 
    this._last_time = SDL.Timer.get_ticks (); 
    this._remainder = 0; 
} 
 
public void cap_frame_rate () 
{ 
    float wait; 
    uint32 frame_time; 
 
    wait = WAIT + this._remainder; 
    this._remainder -= (int)this._remainder; 
 
    frame_time = SDL.Timer.get_ticks () - this._last_time; 
    wait -= frame_time; 
 
    if (wait < 1) 
        wait = 1; 
 
    SDL.Timer.delay ((uint32) wait); 
    this._remainder += REST; 
    this._last_time = SDL.Timer.get_ticks (); 
} 

La diferencia (ademas de pasar los cálculos a 2 métodos fuera del game loop) es el uso de la variable _remainder donde acumulamos la parte decimal del delta frame y lo vamos añadiendo a wait.

Tenemos el ejemplo completo en el repositorio git:

$ git clone https://git.clibre.io/gaming/demogameloop.git

Para construir e instalar:

$ cd demogameloop
$ meson build
$ cd build
$ ninja
$ ninja install

Vamos a usar SDL2-Image a si que tenemos que tener las librerías de desarrollo instaladas en nuestro sistema. En distros Debian/Ubuntu/Elementary :
$ sudo apt install libjpeg-dev libwebp-dev libtiff5-dev
$ sudo apt install libsdl2-image-dev libsdl2-image-2.0-0

DemoGameLoop

Paso de tiempo de actualización fijo y render variable

Vamos a ver otra opción para el game loop. En este caso la idea es actualizar la lógica del juego en pasos fijos, lo que hace todo más sencillo y estable para las físicas y la IA del juego, pero dejar fuera de este control al sistema de renderización (Este game loop es una versión del que muestra Robert Nystrom en Game Programming Patterns basado a su vez en el artículo de Glenn Fiedler: Fix Your Timestep!).

Paso de tiempo de actualización fijo y render variable

La aplicación en código sería:

public void run () { 
    uint32 previous = wsdl.get_time (); 
    double lag = 0.0f; 
 
    while (!input.done) { 
        uint32 current = wsdl.get_time (); 
        uint32 elapsed = current - previous; 
        previous = current; 
        lag += elapsed; 
        wsdl.prepare_scene (); 
        input.process_events (); 
        while (lag >= Wsdl.MS_PER_UPDATE) { 
            update (); 
            lag -= Wsdl.MS_PER_UPDATE; 
        } 
        draw (); 
        wsdl.present_scene (); 
        wsdl.wait (1); 
    } 
} 

wsdl.get_time () es solo una llamada a SDL.Timer.get_ticks ():

public uint32 get_time () { 
    return SDL.Timer.get_ticks (); 
} 

De esta forma hemos separado la actualización del juego, que se realiza en el tiempo fijado por MS_PER_UPDATE, y el render, que se ejecuta siempre que la CPU lo permita.

Esto haría que el juego se renderizara cientos incluso miles de veces por segundo, pero incluyo una opción al crear el render de SDL:

renderer = Video.Renderer.create (window, -1, 
                                            Video.RendererFlags.ACCELERATED | 
                                            Video.RendererFlags.PRESENTVSYNC); 

Al añadir el flag Video.RendererFlags.PRESENTVSYNC lo que hacemos es indicar a SDL que queremos que se sincronice el render con el refresco de pantalla. Este es un sistema que impide el screen tearing, una anomalía que ocurre cuando un dispositivo de visualización muestra información de varios fotogramas en un solo refresco de pantalla.

De esta forma el renderizado queda sincronizado con la frecuencia de refresco de pantalla.

Tenemos el ejemplo completo en el repositorio git:

$ git clone https://git.clibre.io/gaming/demogameloop2.git

Para construir e instalar:

$ cd demogameloop2
$ meson build
$ cd build
$ ninja
$ ninja install

Si ejecutamos la aplicación tendremos un resultado igual que el anterior ejemplo aunque el bucle como hemos visto es ligeramente distinto.

Podemos mostrar los FPS de la animación pero para sacar los datos por pantalla necesitamos manejar fonts con SDL2. Esto lo veremos en la siguiente entrada.

Por ahora esto es todo, espero que pueda resultar de utilidad.

 

Modificado por última vez enSábado, 25 Septiembre 2021 17:14
(5 votos)
Etiquetado como :

Deja un comentario

Asegúrese de introducir toda la información requerida, indicada por un asterisco (*). No se permite código HTML.