8 de Marzo, 2014

Animaciones fluidas con canvas y Javascript

La forma oficial de programar animaciones en Javascript es usando Window.requestAnimationFrame(), con el que indicar al navegador que queremos que una función se ejecute justo antes de redibujar la página (o un canvas en particular).

La teoría dice que de esta forma conseguimos que nuestra función encargada de dibujar (y cuando estamos programando un juego como Flax, el bucle principal del juego), se ejecute a unos 60 cuadros por segundo (según recomendación del W3C, porque además es habitualmente la tasa de refresco de la mayoría de las pantallas; ahora que los monitores CRT han pasado a mejor vida).

Además tenemos que tener en cuenta que requestAnimationFrame no llamará a nuestra función con la misma frecuencia, nuevamente en teoría, si la página no está en primer plano.

requestAnimationFrame está soportado en todos los navegadores modernos, y se puede simular fácilmente en los casos en los que no está disponible (aunque eso puede significar que la implementación de canvas tampoco nos vale).

Con estos datos podríamos asumir que 60 cuadros por segundo es genial y simplemente preparar nuestra función para esa velocidad y todos contentos, pero la realidad es bastante diferente.

Estamos hablando de dibujar en canvas, y el rendimiento va a ser bastante variable en diferente hardware. Por ejemplo: hay gran diferencia entre disponer de una implementación en nuestro navegador que tiene aceleración por hardware para canvas y no tenerla. En Linux es bastante posible que no tengamos aceleración, así que dependiendo de lo que haga nuestro código... es posible que la promesa de ejecutar nuestra función 60 veces por segundo no pueda cumplirse :(.

Esto no es necesariamente un problema si implementamos nuestras animaciones usando una técnica de velocidad de variable. Por ejemplo, si estamos moviendo un objeto en el eje x, en lugar de incrementar en cada llamada a nuestra función su posición en n pixeles, podemos incrementar la posición usando pixeles por segundo. Podemos calcular cuánto tiempo ha transcurrido entre este cuadro y el anterior y multiplicar esa diferencia por el número de pixeles que deseamos si hubiera pasado un segundo (esto se suele expresar como dt*v, siendo dt la diferencia tiempo transcurrido).

Mediante esta técnica conseguimos que la animación sea fluida aunque la máquina sea algo más lenta de lo que esperamos, porque en lugar de incrementar nuestra animación en n, se incrementará en n + k para compensar los cuadros que se pierden en un hardware más lento (siempre con un límite, por supuesto; la calidad de la animación se degradará rápidamente).

Bien, ahora veamos porqué nada de esto es fácil y cómo he solucionado (más o menos) el problema.

En primer lugar la promesa de los 60 cuadros por segundo no se cumple, o no exáctamente. Supongamos el siguiente trozo de código como bucle básico para nuestro juego:

var then = 0;
var cnt = 0;
var loop = function(now) {
	var dt = now-then;

	if(cnt < 250) {
                console.log(cnt++ + ", " + dt);
	}
	// update(dt);
	// draw();

	then = now;
	requestAnimationFrame(loop);
}

loop();

requestAnimationFrame toma un parámetro: la función que queremos que se ejecute. Cuando nuestra función sea ejecutada, tendrá como parámetro el timestamp en el que el navegador llama a la función.

Guardando ese timestamp entre ejecuciones podemos calcular nuestro delta, y en este caso recogemos 250 puntos que pasamos a dibujar a continuación.

requestAnimationFrame, Chrome

requestAnimationFrame, Firefox

He eliminado algunos valores anómalos en los primeros cuadros, probablemente relacionado con el JIT del navegador entrando en escena. Nos encargaremos de eso cuando cubramos caso en el que el navegador puede no llamar a nuestra función si la página no está en primer plano.

El valor de dt está en milisegundos -cosas de Javascript :(-, así que 1000*(1/60.0) es el ~16.66 que deberíamos obtener en cada cuadro. Como podemos observar, no es así.

El caso de Firefox parece incluso peor que el de Chrome, pero para nuestro objetivo tiene poca importancia: no podemos confiar en dt sin más porque no va a ser constante :(.

La solución que he implementado es, en mi opinión, bastante sencilla: basar la animación solo parcialmente en el delta. Si en navegador llama a nuestra función más veces que los 60 cuadros por segundo que esperamos, usamos el dt para ralentizar la animación y mantener la tasa de pixeles por segundo que esperamos, y si el navegador es más lento; asumimos el dt perfecto de 1000/60.

Esto va a causar problemas en máquinas lentas, pero viendo la variabilidad de requestAnimationFrame, tampoco podemos hacer mucho más: el juego correrá lento.

Además con este ajuste evitamos procesar un delta muy grande porque, por ejemplo, la página ha perdido el primer plano por unos segundos. En ese caso obtendremos nuestra tasa máxima ajustada a los 60 cuadros por segundo ideales.

Finalmente yo prefiero trabajar en unidades por segundo, así que convertimos nuestro delta antes de pasarlo a la función que actualice el estado del juego. Esto además tiene la ventaja de suavizar un poco la variabilidad del delta gracias a perder precisión al dibujar.

var then = 0;
var cnt = 0;
var loop = function(now) {
    // dt en segundos
	var dt = Math.min(1000/60, now-then)/1000;

	if(cnt < 250) {
                console.log(cnt++ + ", " + dt);
	}
	// update(dt);
	// draw();

	then = now;
	requestAnimationFrame(loop);
}

loop();

Esta solución se podría mejorar aceptando el valor de delta cuando está por encima de el valor ideal, poniendo un límite máximo pasado el cual nuestra animación no funciona (porque nos saltamos demasiados cuadros), pero en mis pruebas he llegado a la conclusión de que no merece la pena. Si se llega al punto en el que una máquina con potencia razonable no puede mover el juego, es tiempo de optimizar o hacer las cosas de otra forma ;).

Por ejemplo, en Flax escalo el canvas en tiempo real, de forma que jugar a pantalla completa en un monitor grande es más costoso que hacerlo en un netbook; pero siempre es posible hacer la página más pequeña para dar un poco de respiro a la máquina y conseguir los 60 cuadros por segundo.

En otra anotación explicaré el gran problema de usar animaciones basadas en dt en Javascript y canvas en 2D (pista: sub-pixel rendering).

Anotación por Juan J. Martínez, clasificada en: programming, javascript.

Los comentarios están cerrados: los comentarios se cierran automáticamente una vez pasados 30 días. Si quieres comentar algo acerca de la anotación, puedes hacerlo por e-mail.

Algunas anotaciones relacionadas: