Mejora del rendimiento del lienzo HTML5

Introducción

El lienzo de HTML5, que comenzó como un experimento de Apple, es el estándar más compatible con los gráficos en modo inmediato 2D de la Web. Hoy en día, muchos desarrolladores la usan para una amplia variedad de proyectos multimedia, visualizaciones y juegos. Sin embargo, a medida que las aplicaciones que compilamos aumentan en complejidad, los desarrolladores alcanzan el límite de rendimiento de manera involuntaria. Hay muchos conocimientos desconectados sobre la optimización del rendimiento del lienzo. El objetivo de este artículo es consolidar parte de este cuerpo en un recurso más fácil de asimilar para los desarrolladores. En este artículo, se incluyen optimizaciones fundamentales que se aplican a todos los entornos de gráficos por computadora, así como técnicas específicas de lienzos que están sujetas a cambios a medida que mejoran las implementaciones de lienzo. En particular, a medida que los proveedores de navegadores implementan la aceleración de GPU de lienzo, es probable que algunas de las técnicas de rendimiento descritas tengan un impacto menor. Esto se indicará cuando corresponda. Ten en cuenta que este artículo no trata sobre el uso del lienzo de HTML5. Para ello, consulta estos artículos relacionados con el lienzo en HTML5Rocks, este capítulo sobre el sitio Descubre HTML5 o el instructivo sobre MDN Canvas.

Pruebas de rendimiento

Para abordar el mundo rápidamente cambiante del lienzo de HTML5, las pruebas de JSPerf (jsperf.com) verifican que todas las optimizaciones propuestas aún funcionen. JSPerf es una aplicación web que permite a los desarrolladores escribir pruebas de rendimiento de JavaScript. Cada prueba se centra en un resultado que intentas lograr (por ejemplo, borrar el lienzo) e incluye varios enfoques que logran el mismo resultado. JSPerf ejecuta cada enfoque tantas veces como sea posible durante un período corto de tiempo y proporciona una cantidad estadísticamente significativa de iteraciones por segundo. Las puntuaciones más altas siempre son mejores. Los visitantes de la página de prueba de rendimiento de JSPerf pueden ejecutar la prueba en su navegador y permitir que JSPerf almacene los resultados de la prueba normalizados en Browserscope (browserscope.org). Debido a que las técnicas de optimización de este artículo están respaldadas por un resultado de JSPerf, puedes volver a consultar información actualizada sobre si la técnica sigue siendo aplicada o no. Escribí una pequeña aplicación de ayuda que procesa estos resultados como gráficos, incorporada a lo largo de este artículo.

Todos los resultados de rendimiento que aparecen en este artículo están vinculados a la versión del navegador. Esto resulta ser una limitación, ya que no sabemos en qué SO se ejecutaba el navegador o, lo que es aún más importante, si el lienzo HTML5 se aceleró por hardware cuando se ejecutó la prueba de rendimiento. Para saber si el lienzo HTML5 de Chrome está acelerado por hardware, visita about:gpu en la barra de direcciones.

Renderiza previamente en un lienzo fuera de la pantalla

Si vuelves a dibujar primitivas similares a la pantalla en varios fotogramas, como suele suceder cuando escribes un juego, puedes obtener grandes mejoras de rendimiento renderizando previamente grandes partes de la escena. La renderización previa implica usar un lienzo (o lienzos) separado fuera de la pantalla para renderizar imágenes temporales y, luego, renderizar los lienzos fuera de la pantalla en el visible. Por ejemplo, supongamos que vuelves a dibujar a Mario corriendo a 60 fotogramas por segundo. Puedes volver a dibujar su sombrero, bigote y "M" en cada fotograma, o preprocesar a Mario antes de ejecutar la animación. Sin renderización previa:

// canvas, context are defined
function render() {
  drawMario(context);
  requestAnimationFrame(render);
}

renderización previa:

var m_canvas = document.createElement('canvas');
m_canvas.width = 64;
m_canvas.height = 64;
var m_context = m_canvas.getContext('2d');
drawMario(m_context);

function render() {
  context.drawImage(m_canvas, 0, 0);
  requestAnimationFrame(render);
}

Observa el uso de requestAnimationFrame, que se analiza con más detalle en una sección posterior.

Esta técnica es especialmente eficaz cuando la operación de renderización (drawMario en el ejemplo anterior) es costosa. Un buen ejemplo es la renderización de texto, que es una operación muy costosa.

Sin embargo, el rendimiento deficiente del caso de prueba “representada previamente” Cuando realices el procesamiento previo, es importante que te asegures de que el lienzo temporal se ajuste perfectamente a la imagen que estás dibujando; de lo contrario, la mejora de rendimiento de la renderización fuera de la pantalla se compensa con la pérdida de rendimiento de copiar un lienzo grande en otro (que varía como una función del tamaño objetivo de la fuente). En la prueba anterior, un lienzo ajustado es simplemente más pequeño:

can2.width = 100;
can2.height = 40;

En comparación con el lanzamiento suelto que genera un rendimiento más bajo:

can3.width = 300;
can3.height = 100;

Agrupa las llamadas en el lienzo

Como el dibujo es una operación costosa, es más eficiente cargar la máquina de estado de dibujo con un conjunto largo de comandos y, luego, hacer que los vuelque todos en el búfer de video.

Por ejemplo, cuando se dibujan varias líneas, es más eficiente crear una ruta con todas las líneas y dibujarla con una sola llamada de dibujo. En otras palabras, en lugar de dibujar líneas separadas, haz lo siguiente:

for (var i = 0; i < points.length - 1; i++) {
  var p1 = points[i];
  var p2 = points[i+1];
  context.beginPath();
  context.moveTo(p1.x, p1.y);
  context.lineTo(p2.x, p2.y);
  context.stroke();
}

Obtenemos un mejor rendimiento al dibujar una sola polilínea:

context.beginPath();
for (var i = 0; i < points.length - 1; i++) {
  var p1 = points[i];
  var p2 = points[i+1];
  context.moveTo(p1.x, p1.y);
  context.lineTo(p2.x, p2.y);
}
context.stroke();

Esto también se aplica al mundo del lienzo HTML5. Cuando se dibuja una ruta compleja, por ejemplo, es mejor colocar todos los puntos en la ruta, en lugar de renderizar los segmentos por separado (jsperf).

Sin embargo, ten en cuenta que, con Canvas, existe una excepción importante a esta regla: si las primitivas involucradas en el dibujo del objeto deseado tienen cuadros de límite pequeños (por ejemplo, líneas horizontales y verticales), es posible que sea más eficiente renderizarlos por separado (jsperf).

Cómo evitar cambios de estado de lienzo innecesarios

El elemento de lienzo de HTML5 se implementa sobre una máquina de estados que hace un seguimiento de elementos como los estilos de relleno y trazo, así como de los puntos anteriores que conforman la ruta actual. Cuando se intenta optimizar el rendimiento de los gráficos, es tentador enfocarse solamente en la renderización de gráficos. Sin embargo, la manipulación de la máquina de estado también puede generar una sobrecarga de rendimiento. Si usas varios colores de relleno para renderizar una escena, por ejemplo, es más económico renderizarlos por color, en lugar de hacerlo por ubicación en el lienzo. Para renderizar un patrón de rayas, puedes renderizar una franja, cambiar los colores, renderizar la siguiente franja, etcétera:

for (var i = 0; i < STRIPES; i++) {
  context.fillStyle = (i % 2 ? COLOR1 : COLOR2);
  context.fillRect(i * GAP, 0, GAP, 480);
}

También puedes renderizar todas las rayas impares y, luego, todas las pares:

context.fillStyle = COLOR1;
for (var i = 0; i < STRIPES/2; i++) {
  context.fillRect((i*2) * GAP, 0, GAP, 480);
}
context.fillStyle = COLOR2;
for (var i = 0; i < STRIPES/2; i++) {
  context.fillRect((i*2+1) * GAP, 0, GAP, 480);
}

Como se esperaba, el enfoque entrelazado es más lento porque cambiar la máquina de estado es costoso.

Solo las diferencias de la pantalla de renderización, no el estado completamente nuevo

Como se espera, renderizar menos en la pantalla es más económico que renderizar más. Si solo tienes diferencias incrementales entre los reintentos, puedes obtener un aumento importante del rendimiento con solo marcar la diferencia. En otras palabras, en lugar de borrar toda la pantalla antes de dibujar:

context.fillRect(0, 0, canvas.width, canvas.height);

Realiza un seguimiento del cuadro de límite dibujado y solo borra eso.

context.fillRect(last.x, last.y, last.width, last.height);

Si estás familiarizado con los gráficos por computadora, es posible que también conozcas esta técnica como "volver a dibujar regiones", en la que se guarda el cuadro de límite renderizado anteriormente y, luego, se borra en cada renderización. Esta técnica también se aplica a contextos de renderización basados en píxeles, como se ilustra en esta charla del emulador de Nintendo de JavaScript.

Usa lienzos de varias capas para escenas complejas

Como se mencionó antes, dibujar imágenes grandes es costoso y debe evitarse siempre que sea posible. Además de usar otro lienzo para renderizar el contenido fuera de la pantalla, como se ilustra en la sección de renderización previa, también podemos usar lienzos superpuestos. Si usamos la transparencia en el lienzo en primer plano, podemos confiar en que la GPU compone los alfas en conjunto en el tiempo de renderización. Puedes configurar esto de la siguiente manera, con dos lienzos absolutamente posicionados uno encima del otro.

<canvas id="bg" width="640" height="480" style="position: absolute; z-index: 0">
</canvas>
<canvas id="fg" width="640" height="480" style="position: absolute; z-index: 1">
</canvas>

La ventaja en comparación con tener un solo lienzo aquí es que cuando dibujamos o limpiamos el lienzo del primer plano, nunca modificamos el fondo. Si tu juego o app multimedia se puede dividir en primer y segundo plano, considera renderizarlos en lienzos separados para obtener un aumento significativo del rendimiento.

A menudo, puedes aprovechar la percepción humana imperfecta y renderizar el fondo solo una vez o a una velocidad menor en comparación con el primer plano (lo que probablemente ocupará la mayor parte de la atención del usuario). Por ejemplo, puedes renderizar el primer plano cada vez que renderizas, pero hacerlo solo en cada enésimo fotograma. Además, ten en cuenta que este enfoque se generaliza bien para cualquier cantidad de lienzos compuestos si tu aplicación funciona mejor con este tipo de estructura.

Evita el desenfoque de las sombras

Al igual que muchos otros entornos gráficos, el lienzo de HTML5 permite a los desarrolladores difuminar las primitivas, pero esta operación puede ser muy costosa:

context.shadowOffsetX = 5;
context.shadowOffsetY = 5;
context.shadowBlur = 4;
context.shadowColor = 'rgba(255, 0, 0, 0.5)';
context.fillRect(20, 20, 150, 100);

Conoce varias formas de despejar el lienzo

Dado que el lienzo de HTML5 es un paradigma de dibujo del modo inmediato, la escena debe volver a dibujarse explícitamente en cada marco. Por este motivo, borrar el lienzo es una operación fundamental para las apps y los juegos de lienzo HTML5. Como se mencionó en la sección Cómo evitar los cambios de estado del lienzo, borrar todo el lienzo suele ser no deseado, pero si debes hacerlo, hay dos opciones: llamar a context.clearRect(0, 0, width, height) o usar un truco específico del lienzo para hacerlo: canvas.width = canvas.width. En el momento de escribir, clearRect suele superar el rendimiento de la versión de restablecimiento del ancho, pero, en algunos casos, el hackeo de canvas.width es mucho más rápido en Chrome 14.

Ten cuidado con esta sugerencia, ya que depende en gran medida de la implementación de lienzo subyacente y está muy sujeta a cambios. Para obtener más información, consulta el artículo de Simon Sarris sobre cómo borrar el lienzo.

Evita las coordenadas de punto flotante

El lienzo de HTML5 admite el procesamiento de subpíxeles y no hay forma de desactivarlo. Si dibujas con coordenadas que no son números enteros, automáticamente usa el suavizado de contorno para intentar suavizar las líneas. A continuación, se muestra el efecto visual extraído de este artículo de rendimiento sobre el lienzo de subpíxeles de Seb Lee-Delisle:

Subpíxel

Si el objeto suavizado no es el efecto que buscas, puede ser mucho más rápido convertir las coordenadas en números enteros con Math.floor o Math.round (jsperf):

Para convertir las coordenadas de punto flotante en números enteros, puedes usar varias técnicas ingeniosas. Las más eficaces incluyen agregar una mitad al número objetivo y, luego, realizar operaciones a nivel de bits en el resultado para eliminar la parte fraccionaria.

// With a bitwise or.
rounded = (0.5 + somenum) | 0;
// A double bitwise not.
rounded = ~~ (0.5 + somenum);
// Finally, a left bitwise shift.
rounded = (0.5 + somenum) << 0;

El desglose completo del rendimiento se encuentra aquí (jsperf).

Ten en cuenta que este tipo de optimización ya no debería importar una vez que las implementaciones de lienzo se aceleren por la GPU, lo que podrá renderizar rápidamente coordenadas que no sean números enteros.

Optimiza tus animaciones con requestAnimationFrame

Se recomienda usar la API de requestAnimationFrame relativamente nueva para implementar aplicaciones interactivas en el navegador. En lugar de ordenar al navegador para que se renderice a una frecuencia de marcas fija específica, pídele amablemente que llame a la rutina de renderización y que se lo llame cuando esté disponible. Como efecto secundario, si la página no está en primer plano, el navegador es lo suficientemente inteligente como para no renderizarlo. La devolución de llamada requestAnimationFrame busca una tasa de devolución de llamada de 60 FPS, pero no lo garantiza, por lo que debes hacer un seguimiento del tiempo transcurrido desde la última renderización. Esto puede verse de la siguiente manera:

var x = 100;
var y = 100;
var lastRender = Date.now();
function render() {
  var delta = Date.now() - lastRender;
  x += delta;
  y += delta;
  context.fillRect(x, y, W, H);
  requestAnimationFrame(render);
}
render();

Ten en cuenta que este uso de requestAnimationFrame se aplica al lienzo y a otras tecnologías de renderización, como WebGL. Al momento de la redacción, esta API solo está disponible en Chrome, Safari y Firefox, por lo que debes usar esta corrección de compatibilidad.

La mayoría de las implementaciones de lienzo para dispositivos móviles son lentas

Hablemos de los dispositivos móviles. Lamentablemente, en el momento de la escritura, solo la versión beta de iOS 5.0 que ejecuta Safari 5.1 tiene la implementación de lienzo para dispositivos móviles con aceleración de GPU. Sin la aceleración de GPU, los navegadores para dispositivos móviles no suelen tener CPU lo suficientemente potentes para las aplicaciones modernas basadas en lienzos. Varias de las pruebas de JSPerf descritas anteriormente tienen un rendimiento de magnitud peor en los dispositivos móviles que en las computadoras de escritorio, lo que restringe en gran medida los tipos de apps multidispositivo que puedes esperar que se ejecuten de forma correcta.

Conclusión

En resumen, este artículo cubrió un conjunto integral de técnicas de optimización útiles que te ayudarán a desarrollar proyectos de buen rendimiento basados en lienzos HTML5. Ahora que aprendiste algo nuevo, optimiza tus increíbles creaciones. O bien, si no tienes un juego o una aplicación para optimizar, consulta Chrome Experiments y Creative JS para inspirarte.

Referencias