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.
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.
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 objetofor (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 finalconst 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 buclectx.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 copiaEl 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.