Mejorando el rendimiento de las animaciones WebGL

8 de febrero de 2016

La nueva API OffscreenCanvas de JavaScript permite ejecutar el código relacionado con WebGL fuera del "main thread" o hilo principal de ejecución del navegador, lo que mejora significativamente el rendimiento.

Si usas Firefox, a partir de su versión 44 ya puedes hacer uso de esta API. Si en tu versión no está activada, teclea about:config en la barra de direcciones, busca gfx.offscreencanvas.enabled y cambia su valor a true.

Si quieres seguir los ejemplos de este tutorial con más detalle, puedes echar un vistazo al código fuente en GitHub y también puedes ejecutar el ejemplo en tu navegador.

Casos de uso

Esta API es la primera que permite utilizar un "thread" o hilo de ejecución distinto del principal para modificar lo que el usuario ve en el navegador. Esto permite seguir renderizando contenidos sin importar lo que suceda en el hilo principal.

En la especificación oficial de esta característica se comentan otros posibles casos de uso de esta API.

Modificando tu código

Vamos a utilizar como ejemplo una animación básica creada con WebGL que utilicé en mi presentación "Raw WebGL". Lo que vamos a hacer es modificar el código para ejecutarlo en un hilo diferente al principal.

Animación WebGL básica

El primer paso consiste en pasar a un archivo diferente todo el código relacionado con la creación del contexto WebGL. El siguiente código:

<script src="gl-matrix.js"></script>
<script>
  // hilo principal
  var canvas = document.getElementById('myCanvas');

  // ...
  gl.useProgram(program);
  // ...

Se transforma en este otro código:

// hilo principal
var canvas = document.getElementById('myCanvas');
if (!('transferControlToOffscreen' in canvas)) {
  throw new Error('webgl in worker unsupported');
}

var offscreen = canvas.transferControlToOffscreen();
var worker = new Worker('worker.js');
worker.postMessage({ canvas: offscreen }, [offscreen]);
// ...

Observa que el nuevo código llama a HTMLCanvasElement.prototype.transferControlToOffscreen y pasa el resultado al nuevo worker. transferControlToOffscreen devuelve un nuevo objeto que es una instancia de OffscreenCanvas, que es diferente al tradicional objeto HTMLCanvasElement.

Aunque los dos objetos son similares, en OffscreenCanvas no puedes acceder a propiedades como offscreen.clientWidth y offscreen.clientHeight. Si que puedes acceder en cambio a las propiedades offscreen.width y offscreen.height. Al pasar el objeto offscreen como segundo argumento del método postMessage(), se transfiere la propiedad de esa variable al segundo hilo de ejecución.

A continuación, en el nuevo hilo se espera hasta recibir el mensaje del hilo principal con el elemento canvas. Después, se obtiene el contexto WebGL. El código que obtiene el contexto, crea y rellena los buffers, gestiona los atributos y dibuja los contenidos no sufre ninguna modificación.

// hilo de ejecución del worker
importScripts('gl-matrix.js');

onmessage = function (e) {
  if (e.data.canvas) {
    createContext(e.data.canvas);
  }
};

function createContext (canvas) {
  var gl = canvas.getContext('webgl');
  ...

OffScreenCanvas solamente añade a WebGLRenderingContext.prototype un método llamado commit(). Este método envía la imagen renderizada al elemento canvas que creó el OffscreenCanvas utilizado por el contexto WebGL.

Sincronizando las animaciones

Para hacer que la animación se ejecute, desde el hilo principal se envían al otro hilo mediante postMessage las peticiones de requestAnimationFrame para renderizar un frame de la animación:

// hilo principal
(function tick (t) {
  worker.postMessage({ rAF: t });
  requestAnimationFrame(tick);
})(performance.now());

En el otro archivo JavaScript, se modifica el método onmessage() para recibir los nuevos mensajes:

// hilo de ejecución del worker
onmessage = function (e) {
  if (e.data.rAF && render) {
    render(e.data.rAF);
  } else if (e.data.canvas) {
    createContext(e.data.canvas);
  }
};

Por último, la función que renderiza la animación debe añadir al final una llamada a gl.commit(). Código original:

// hilo principal
function render (dt) {
  // update
  ...
  // render
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  gl.drawArrays(gl.TRIANGLES, 0, n);
  requestAnimationFrame(render);
};

Código modificado:

// hilo de ejecución del worker
function render (dt) {
  // update
  ...
  // render
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
  gl.drawArrays(gl.TRIANGLES, 0, n);
  gl.commit(); // <-- añade esta línea
};

Limitaciones

La animación de este ejemplo es muy básica, por lo que no está basada en la velocidad sino en el frame rate. Aún así, la técnica explicada en este artículo puede crear algunos problemas.

Imagina que se ha movido el código WebGL a su propio hilo de ejecución para evitar las pausas que provoca el "GC" o recolector de basura de la máquina virtual de JavaScript. Las pausas del GC en el hilo principal también afectarán a las llamadas a requestAnimationFrame. Además, como las llamadas a gl.drawArrays y gl.commit se ejecutan asíncronamente en el worker pero están dentor del bucle requestAnimationFrame del hilo principal, las pausas del GC bloquearán el renderizado del hilo del worker.

El comportamiento anterior es debido a nuestro propio código. En realidad, las pausas GC del hilo principal no afectan al resto de hilos de los workers (al menos en Firefox). Cada hilo tiene su propio recolector de basura.

Por el momento este problema no está solucionado, pero ya se está trabajando en ello. La solución será hacer que requestAnimationFrame esté disponible en el contexto del worker.

En resumen

Gracias a la API OffscreenCanvas ahora es posible renderizar contenidos en el navegador sin bloquear el hilo principal de ejecución. Aunque todavía hay que mejorar cosas como hacer que requestAnimationFrame esté disponible en los workers, ya se puede usar esta funcionalidad en aplicaciones reales. Para una comparación real, echa un vistazo a animation.html, animation-worker.html y a worker.js.

Sobre el autor

Este artículo fue publicado originalmente por Nick Desaulniers y ha sido traducido con permiso por Javier Eguiluz.