domingo, 6 de noviembre de 2011

[Culebrilla] Empezando con el juego: El problema del tiempo

En la entrada anterior vimos cómo se encarna en código (de una forma muy simple) el famoso bucle general de los videojuegos. También dijimos que era una chapuza. ¿Por qué? Muy simple: porque no tiene en cuenta el tiempo y es totalmente dependiente de la velocidad de proceso del hardware donde funciona. Recordemos el código que usamos en la última entrada:

// Inicializamos las cosas...
Img bulletImg = loadImg("bala.png");
bool done = false;
float bulletPos = 0;
while(!done)
{
 // Actualizamos el estado
 bulletPos += 1;
 if(800 <= bulletPos)
 {
  done = true;
 }
 
 // Pintamos la bala
 drawSprite(bulletImg, bulletPos, 300);
}
// Liberamos las cosas
freeImg(bulletImg);

He añadido cosas como loadImg() y freeImg(), además de declarar las variables para completar un poco el ejemplo. Estas funciones son inventadas de raíz, pero el primero se supone que cargaría el fichero indicado y el segundo liberaría lo creado por el primero. Un poco como el new/delete de C++ (o malloc/free de C).

Bien, ¿Qué problema tiene el bucle para llamarlo chapuza? Pues que la bala se moverá un píxel por frame (por cada iteración). El problema de esto es que dependiendo del hardware donde se ejecute puede tardar más o menos en ejecutar cada iteración. Esto implica que por ejemplo en una máquina pueda hacer 100 iteraciones por segundo, pero en otra menos potente puede que sólo haga 20. Si este código fuese para una consola o un hardware específico quizás podríamos dejarlo tal cual, ya que tendríamos la garantía de que todas las unidades de ese hardware tardan lo mismo en ejecutar el mismo código. Si queremos que nuestro juego vaya en PC no podemos dejarlo así ni mucho menos.

Imaginemos este caso para tener más claro el problema: tenemos un PC que puede procesar 100 veces por segundo el bucle. En este caso diríamos que el juego va a 100 FPS, fotogramas por segundo o frames per second en inglés. Tenemos otro PC que es menos potente y que sólo puede ejecutar el bucle a 50 FPS. En un segundo, en el primer caso la bala recorrería 100 píxeles, mientras que en el mismo lapso de tiempo el segundo PC lo hubiese móvido sólo 50. Es decir, en el segundo PC el juego iría a la mitad de velocidad, afectando seriamente a la jugabilidad. Lo que es más, imaginemos que se lo pasamos a un amigo con un PC mucho mas potente que mueve el juego a 1000 FPS. ¿Quien podría jugar a un juego donde todo va tan deprisa?

Con esto queda claro que no podemos usar el bucle tal cual, necesitamos que el juego vaya de la misma forma en todos los PCs. Sea cual sea el hardware (dentro de unos límites, claro) las balas del juego deben moverse a la misma velocidad, el personaje debe saltar lo mismo, etc. ¿Cómo podemos hacerlo? La clave es que el movimiento y demás cálculos sean independientes de la frecuencia de las iteraciones (framerate), de forma que en vez de expresar la velocidad de una bala en píxeles/frame lo expresemos como píxeles/segundo. Cuando en el bucle que tenemos ahí arriba hacemos bulletPos += 1 estamos indicando que la bala se mueve 1 píxel por frame, cosa que hemos visto no es aceptable. Debemos poder moverlo 1 píxel por segundo, es decir, controlar el tiempo para poder hacer los cálculos correctamente.

Hay 2 formas habituales de hacer el control del tiempo: "Variable time step" y "fixed time step". Ambos parten de la misma idea: calculamos el tiempo que nos ha llevado ejecutar el último frame y usamos este valor para actualizar el estado actual. Veamos cómo funciona cada método:

Variable time step
En cada iteración obtenemos el tiempo que ha pasado desde el último frame hasta el momento actual. Usando este valor se escalan todos los cálculos que dependan del tiempo. El bucle de nuestro ejemplo quedaría así:

float oldTime = currentTime();
while(!done)
{
 float now = currentTime();
 // Calculamos cuanto segundos han pasado desde el último frame
 float deltaTime = now - oldTime;
 oldTime = now;
 // Actualizamos el estado
 bulletPos += 1 * deltaTime;
 if(800 <= bulletPos)
 {
  done = true;
 }
 
 // Pintamos la bala
 drawSprite(bulletImg, bulletPos, 300);
}

La función currentTime() devuelve el número de segundos que han pasado desde que se inició el programa con el tipo float. Es decir, si ha pasado medio segundo por ejemplo devolverá el valor 0.5. Es una función inventada para el ejemplo como puede ser drawSprite(). Declaramos la variable oldTime y le asignamos los segundos que han pasado hasta ese momento.

En cada iteración calculamos el tiempo que ha tardado en procesarse el último frame: restamos los segundos que han pasado hasta este momento con los que obtuvimos en la iteración anterior y lo asignamos a la variable "deltaTime". Luego asignamos los segundos del momento actual (la variable "now") a la variable "oldTime" para tener este valor en la siguiente iteración.

Una vez calculado deltaTime lo usamos para escalar el desplazamiento de la bala. Como se mueve un píxel por segundo y han pasado deltaTime segundos, añadimos a bulletPos la cantidad 1 * deltaTime. Así, si ha pasado un segundo se movería 1 píxel, si han pasado 10 segundos se movería 10, y si ha pasado una décima de segundo se movería 0.1 píxeles. Como se ve la posición debe ser un valor de coma flotante, sino perdemos esta precisión de mover las cosas menos de un píxel.

Fixed time step
La idea de este método es definir un intervalo fijo para los cálculos y ejecutar tantas veces estos cálculos como sea necesario. Nuestro bucle quedaría así implementando este método:

const float LogicTime = 1.0 / 60.0; // 1/60 segundos cada paso de la lógica
float oldTime = currentTime();
float deltaTime = 0;
while(!done)
{
 float now = currentTime();
 deltaTime += now - oldTime;
 oldTime = now;

 while(LogicTime < deltaTime)
 {
  // Actualizamos el estado
  bulletPos += 1.0 / 60.0;
  if(800 <= bulletPos)
  {
   done = true;
  }

  deltaTime -= LogicTime;
 }

 // Pintamos la bala
 drawSprite(bulletImg, bulletPos, 300);
}

Primeramente definimos la frecuencia a la que queremos que se ejecute la lógica del juego. En este ejemplo lo hemos puesto a 60 veces por segundo. Después declaramos la variable oldTime como antes pero también definimos la variable deltaTime, la cual estaba dentro del while en el caso del variable time step. Esto es necesario para llevar un control preciso sobre el tiempo, como veremos ahora.

Dentro del primer while calculamos el tiempo que ha pasado del frame anterior, pero en vez de asignar este lapso de tiempo a deltaTime se lo sumamos. A continuación tenemos otro while, que es la parte fundamental del fixed time step: actualizamos el estado tantas veces como LogicTime-s contenga deltaTime. Es decir, si deltaTime es 1 segundo este while se ejecutará 60 veces. Si es 0.5 segundos se ejecutará 30 veces y si es 1/60 segundos se ejecutará 1 vez. Si deltaTime es un valor inferior a 1/60 segundos no se ejecutará nada, se pintará la bala y estaremos otra vez al principio del bucle general. Es aquí donde se ve el sentido de añadir y no asignar el lapso de tiempo a deltaTime: si el lapso de tiempo es inferior a LogicTime hay que ir acumulando los lapsos de tiempo hasta llegar a ser igual o superior a este valor para poder ejecutar la lógica del juego. De la misma forma si el framerate no es un múltiplo de LogicTime al terminar de ejecutar el segundo while deltaTime tendrá cierto tiempo residual (por decirlo de alguna forma) y este tiempo no se puede desechar, hay que tenerlo en cuenta para la siguiente iteración.

Como se puede observar a bulletPos ya no le sumamos 1 sino 1/60, que es lo que se tiene que mover en una frecuencia de 60 veces por segundo si queremos que se mueva 1 píxel por segundo.

Ya hemos visto los dos métodos típicos para controlar el tiempo. Como veis el concepto general es similar: cuanto más grande sea el lapso de tiempo más movemos la bala, ya sea haciendo más exagerado su movimiento o ejecutando más veces la lógica del juego y viceversa (a menor lapso de tiempo menos desplazamiento por frame). Además ambos métodos también sirven para hacer frente a lapsos de tiempo no constantes, ya que lo normal suele ser que varíe durante la ejecución. Es decir, en cada iteración del bucle principal podemos tener un lapso de tiempo diferente.

Dicho esto ¿Qué método escogemos? Depende un poco de las preferencia de cada uno. En mi caso prefiero el fixed time step, lo encuentro más sencillo y no hay que ir multiplicando deltaTime a cada variable dependiente del tiempo. Para el Culebrilla usaremos este método y también para los futuros juegos del blog. Los dos métodos tampoco son excluyentes: se puede usar el fixed time step para la lógica del juego y el variable time step para los gráficos, interpolando el movimiento y las animaciones con deltaTime. Por último aquí tenéis un artículo (en inglés) hablando sobre este tema, por si queréis otro enfoque: http://www.iguanademos.com/Jare/Articles.php?view=FixLoop

En la siguiente entrada hablaremos un poco más sobre el control del tiempo. ¡Hasta pronto!


No hay comentarios:

Publicar un comentario