wandres.dev
RENDIMIENTO DEL CANVAS · Lo que realmente cuesta

Lo que de verdad cuesta: los cambios de estado

Descubrir dónde se va el tiempo de un canvas: no en el número de figuras sino en cambiar de estilo, en la pila de estado y en tres operaciones desproporcionadamente caras.

⏱ 18 min

La intuición dice que un canvas lento dibuja demasiadas cosas, y la mayoría de las veces es falsa. Un canvas que pinta veinte mil rectángulos del mismo color va sobrado; el mismo canvas pintando veinte mil rectángulos de veinte mil colores distintos puede no llegar a tiempo. La diferencia no está en los píxeles sino en cuántas veces el motor tiene que reconfigurarse entre una orden y la siguiente, y esa cuenta no aparece en ningún sitio salvo que sepas buscarla.

🎯 Al terminar esta lección sabrás
  • Distinguir el coste por figura del coste por cambio de estado.
  • Agrupar las llamadas de dibujo por estilo y medir la mejora.
  • Usar la pila de estado sin pagarla más de lo necesario.
  • Identificar las tres operaciones cuyo coste es desproporcionado.

Lo que cuesta no es lo que parece

Un canvas 2D acelerado no ejecuta tus órdenes cuando las escribes: las graba en una lista de comandos que se envía al hardware más tarde. Sobre ese modelo, tres cosas cuestan de verdad.

Cambiar una propiedad del contexto. Asignar fillStyle no es escribir un campo: obliga a analizar una cadena CSS, resolver el color y marcar como inválida la configuración de pintura acumulada. Lo mismo con font, strokeStyle, lineWidth, globalAlpha y compañía. Con un color por objeto, el análisis de cadenas puede ser la partida más grande de tu presupuesto.

Rellenar píxeles. Es lo que hace el hardware y escala con el área, no con el número de figuras. Doscientos rectángulos pequeños cuestan mucho menos que uno que cubra la pantalla, aunque haya doscientas veces más llamadas.

Cruzar la frontera de JavaScript. Cada llamada al contexto valida argumentos y convierte tipos. Es poco por llamada y significativo cuando son cientos de miles.

Antes de optimizar nada hay que saber en cuál de las tres estás, y para eso hace falta un banco de pruebas honesto:

function medir(nombre, veces, fn) {
  fn();                                    // calentar
  const t0 = performance.now();
  for (let i = 0; i < veces; i++) fn();
  const t1 = performance.now();
  console.log(nombre, ((t1 - t0) / veces).toFixed(3), 'ms por pasada');
}

const N = 20000;
const puntos = Array.from({ length: N }, () => ({
  x: Math.random() * 800, y: Math.random() * 600,
  color: `hsl(${Math.random() * 360} 70% 60%)`,
}));

medir('un solo color', 20, () => {
  ctx.clearRect(0, 0, 800, 600);
  ctx.fillStyle = '#89b4fa';
  for (const p of puntos) ctx.fillRect(p.x, p.y, 4, 4);
});

medir('un color por objeto', 20, () => {
  ctx.clearRect(0, 0, 800, 600);
  for (const p of puntos) {
    ctx.fillStyle = p.color;               // 20000 analisis de cadena
    ctx.fillRect(p.x, p.y, 4, 4);
  }
});

En cualquier máquina moderna la segunda versión tarda varias veces más que la primera dibujando exactamente los mismos píxeles. Ese factor es la medida de lo que cuestan los cambios de estado en tu hardware, y merece la pena obtenerlo una vez para tener la cifra en la cabeza.

Agrupar por estado

Si cambiar de estilo es lo caro, la optimización evidente es cambiar menos veces: ordenar el trabajo por estilo y dibujar todo lo que comparte configuración de una vez.

function dibujarAgrupado(ctx, objetos) {
  const porColor = new Map();
  for (const o of objetos) {
    let grupo = porColor.get(o.color);
    if (!grupo) porColor.set(o.color, (grupo = []));
    grupo.push(o);
  }
  for (const [color, grupo] of porColor) {
    ctx.fillStyle = color;                 // un cambio por grupo
    ctx.beginPath();
    for (const o of grupo) ctx.rect(o.x, o.y, o.ancho, o.alto);
    ctx.fill();                            // un solo relleno para todo el grupo
  }
}

Ese fragmento aplica dos optimizaciones a la vez y conviene no confundirlas. La primera es el agrupamiento por color. La segunda, y suele ser mayor, es que una sola ruta con muchos subtrazos se rellena de una vez: beginPath seguido de mil rect y un fill es sustancialmente más rápido que mil fillRect, porque hay una orden de relleno en lugar de mil.

Dos avisos importantes antes de aplicarlo a ciegas.

El agrupamiento cambia el orden de pintado. Si tus objetos se solapan, agrupar por color altera quién queda encima. Solo es seguro cuando no hay solapamiento, o cuando agrupas dentro de cada capa de profundidad en lugar de globalmente.

La agrupación cuesta. Construir un Map y varios arrays por fotograma tiene su precio y genera basura. Si los estilos son estables, el agrupamiento se calcula una vez y se guarda; recalcularlo cada fotograma puede comerse la ganancia.

El caso límite de esta idea es reducir la paleta a propósito. Un mapa de calor con dieciséis colores discretos se dibuja en dieciséis pasadas; el mismo mapa con un color continuo por celda, en decenas de miles. La diferencia visual suele ser inapreciable y la de rendimiento, enorme.

Medir el dibujo con performance.now miente, porque las órdenes que mides todavía no se han ejecutado

Este es el error de método que invalida la mitad de los bancos de prueba de canvas que circulan por ahí, incluido el de esta misma lección si no se interpreta bien. En un canvas acelerado, tus llamadas graban comandos y vuelven inmediatamente: el trabajo real de rasterizado lo hace el hardware después, en paralelo, y puede no haber terminado cuando tu performance.now() final se ejecuta. Lo que has medido es el coste de grabar, no el de dibujar. La consecuencia es que un banco de pruebas ingenuo mide con bastante fidelidad los cambios de estado y las llamadas a la API —que sí ocurren en tu hilo— y es prácticamente ciego a la tasa de relleno, que es justo el otro gran factor. Por eso puedes “demostrar” que dibujar un rectángulo de pantalla completa es gratis mientras la animación va a treinta fotogramas. Hay tres maneras de medir bien y ninguna es perfecta. La primera y la que deberías usar por defecto: no midas las llamadas, mide el tiempo entre fotogramas. Si el fotograma completo dura dieciséis milisegundos, todo cabe; si dura treinta, algo no cabe, y da igual dónde diga tu cronómetro que está el tiempo. La segunda: forzar la sincronización con una lectura de un píxel, ctx.getImageData(0, 0, 1, 1), justo antes de parar el reloj. Eso obliga a vaciar la cola y esperar al hardware, con lo que la medida incluye el rasterizado; el precio es que cambias las condiciones de lo que mides, porque introduces una barrera que en producción no existe, y el detalle de por qué está en el coste real de leer del canvas. Úsala para comparar dos versiones entre sí, nunca para dar un número absoluto. La tercera: el panel de rendimiento del navegador, que muestra la actividad del proceso de GPU en una pista aparte y es la única fuente fiable sobre si el cuello está en el rasterizado. Y una prueba de diagnóstico que vale más que cualquier instrumentación y ocupa una línea: reduce el canvas a la mitad de resolución y vuelve a medir. Si el tiempo por fotograma cae aproximadamente a la cuarta parte, tu problema es la tasa de relleno y ninguna optimización de llamadas te va a salvar. Si no cambia casi nada, tu problema está en el hilo de JavaScript y ahí sí sirve todo lo demás de este nivel.

La pila de estado y las rutas

save y restore son baratos comparados con un cambio de estilo, pero no son gratis: save copia el estado completo del contexto a una pila, incluida la región de recorte. En un bucle de cien mil iteraciones eso se nota.

// Version con pila: clara y con un coste por objeto
for (const o of objetos) {
  ctx.save();
  ctx.translate(o.x, o.y);
  ctx.rotate(o.angulo);
  ctx.fill(o.ruta);
  ctx.restore();
}

// Version con matriz explicita: mas rapida, hay que acordarse de restaurar al final
const cos = Math.cos, sin = Math.sin;
for (const o of objetos) {
  const c = cos(o.angulo), s = sin(o.angulo);
  ctx.setTransform(c * dpr, s * dpr, -s * dpr, c * dpr, o.x * dpr, o.y * dpr);
  ctx.fill(o.ruta);
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

La segunda versión evita la pila entera y una llamada por objeto. Es más frágil —si algo falla a mitad, la matriz queda mal— y en escenas muy densas la diferencia es real. El detalle completo del comportamiento de la pila está en la pila de estado; aquí lo que importa es que la pila es una herramienta de corrección, no un coste despreciable, y que en el bucle interno de una escena densa se puede prescindir de ella.

Sobre las rutas, tres hechos con consecuencias directas.

beginPath es obligatorio y olvidarlo es un problema de rendimiento además de visual. Sin él, la ruta crece indefinidamente y cada fill vuelve a rasterizar todo lo acumulado desde el principio. Es la causa clásica de una animación que empieza fluida y se degrada progresivamente hasta congelarse.

Reutilizar geometría con Path2D ahorra la construcción, con el detalle de dónde está exactamente el ahorro explicado en Path2D como objeto.

Los rectángulos alineados a la rejilla son un camino rápido en todos los motores. fillRect con coordenadas enteras evita el antialiasing de bordes y activa optimizaciones que una ruta genérica no puede usar. Si tu escena son barras o celdas, alinéalas a píxeles enteros según lo visto en el medio píxel.

Las tres operaciones desproporcionadas

Hay tres cosas cuyo coste no guarda ninguna relación con lo que aparentan, y que aparecen en el perfil como un muro.

Las sombras. Asignar shadowBlur a un valor mayor que cero convierte cada operación de dibujo en un desenfoque gaussiano sobre una copia de la figura, y el coste crece con el cuadrado del radio. Una sombra de veinte píxeles en doscientos objetos puede multiplicar por diez el tiempo de la escena. La alternativa casi siempre es una imagen pre-renderizada con la sombra ya incorporada, o un gradiente radial dibujado a mano.

// Carisimo dentro de un bucle
ctx.shadowBlur = 20;
ctx.shadowColor = 'rgba(0,0,0,0.4)';
for (const o of objetos) ctx.fill(o.ruta);

// Barato: la sombra se dibuja una vez en una cache y se copia

El filtro del contexto. ctx.filter acepta la sintaxis de los filtros de CSS y es igual de caro que ellos: cada dibujo pasa por un pipeline de efectos adicional. Fuera de un bucle es una herramienta magnífica; dentro, es una manera de perder el fotograma.

El texto. fillText es de las operaciones más caras de la API porque implica seleccionar la fuente, dar forma al texto —resolver ligaduras y kerning— y rasterizar glifos. Y asignar ctx.font con una cadena distinta obliga a rehacer la selección de fuente. Dos reglas: no reasignes font si no ha cambiado, y cachea las medidas en lugar de llamar a measureText por fotograma, con las métricas que se detallan en measureText completo.

const anchos = new Map();
function anchoDe(texto, fuente) {
  const clave = fuente + '|' + texto;
  let a = anchos.get(clave);
  if (a === undefined) {
    if (ctx.font !== fuente) ctx.font = fuente;
    a = ctx.measureText(texto).width;
    anchos.set(clave, a);
  }
  return a;
}

El texto es también el mejor candidato de toda la API para la técnica de la caché en mapa de bits, que es la siguiente pieza del nivel.