wandres.dev
RENDIMIENTO DEL CANVAS · Lo que realmente cuesta

Gradientes y objetos creados dentro del bucle

Sacar del fotograma todo lo que se puede construir una vez: gradientes, patrones, cadenas de color y medidas, y controlar la basura que genera un bucle de dibujo.

⏱ 17 min

Hay una familia de errores de rendimiento que comparten la misma forma: crear dentro del bucle de dibujo un objeto que podría existir desde el principio. Los gradientes son el caso famoso, pero la lista incluye los patrones, las cadenas de color construidas con plantillas, las medidas de texto y cualquier array temporal. Todos cuestan poco por unidad y todos se multiplican por el número de objetos y por sesenta veces por segundo, que es el factor que convierte lo despreciable en lo dominante.

🎯 Al terminar esta lección sabrás
  • Crear gradientes y patrones fuera del bucle y colocarlos con la matriz.
  • Cachear con clave lo que depende de parámetros que cambian.
  • Eliminar la construcción de cadenas de color por fotograma.
  • Reconocer y reducir la presión sobre el recolector de basura.

El gradiente que se crea sesenta veces por segundo

// El clasico: un gradiente nuevo por objeto y por fotograma
function dibujar(ctx, barras) {
  for (const b of barras) {
    const g = ctx.createLinearGradient(0, b.y, 0, b.y + b.alto);
    g.addColorStop(0, '#89b4fa');
    g.addColorStop(1, '#1e66f5');
    ctx.fillStyle = g;
    ctx.fillRect(b.x, b.y, b.ancho, b.alto);
  }
}

Crear un CanvasGradient implica reservar el objeto, guardar sus paradas y, la primera vez que se usa, generar internamente la rampa de color interpolada. Con cien barras y sesenta fotogramas son seis mil gradientes por segundo, con sus seis mil rampas y su basura correspondiente.

La corrección aprovecha un detalle del modelo que mucha gente desconoce: las coordenadas del gradiente se transforman con la matriz vigente en el momento de pintar, no con la que hubiera al crearlo. Eso permite crear un único gradiente en el origen y colocarlo con translate.

// Una vez, al inicializar
const gradienteBarra = ctx.createLinearGradient(0, 0, 0, 1);
gradienteBarra.addColorStop(0, '#89b4fa');
gradienteBarra.addColorStop(1, '#1e66f5');

// En cada fotograma
function dibujar(ctx, barras) {
  ctx.fillStyle = gradienteBarra;
  for (const b of barras) {
    ctx.save();
    ctx.translate(b.x, b.y);
    ctx.scale(b.ancho, b.alto);            // el gradiente unitario se estira con la barra
    ctx.fillRect(0, 0, 1, 1);
    ctx.restore();
  }
}

El gradiente unitario —definido entre cero y uno— es el patrón que resuelve el caso general: se escala al tamaño que haga falta y sirve para cualquier barra. Cuando la escala no es aceptable porque deformaría el trazo, la alternativa es crear un gradiente por cada tamaño distinto y cachearlo.

Los patrones tienen su propia versión del mismo truco, y además una API específica para transformarlos sin tocar el contexto:

const patron = ctx.createPattern(imagenTextura, 'repeat');
patron.setTransform(new DOMMatrix().translate(desplazamiento, 0).scale(0.5));
ctx.fillStyle = patron;
ctx.fillRect(0, 0, ancho, alto);

setTransform sobre el patrón permite desplazarlo o escalarlo en cada fotograma sin recrearlo, que es exactamente lo que hace falta para un fondo que se mueve.

La caché con clave

Cuando el objeto sí depende de parámetros variables, la respuesta no es crearlo cada vez sino cachearlo por su clave.

class CacheDePintura {
  constructor(ctx, limite = 64) {
    this.ctx = ctx;
    this.limite = limite;
    this.mapa = new Map();
  }

  gradienteVertical(alto, c0, c1) {
    const clave = `v|${alto}|${c0}|${c1}`;
    let g = this.mapa.get(clave);
    if (g) {
      this.mapa.delete(clave);            // reinsertar lo mantiene "reciente"
      this.mapa.set(clave, g);
      return g;
    }
    g = this.ctx.createLinearGradient(0, 0, 0, alto);
    g.addColorStop(0, c0);
    g.addColorStop(1, c1);
    this.mapa.set(clave, g);
    if (this.mapa.size > this.limite) {
      this.mapa.delete(this.mapa.keys().next().value);   // el menos reciente
    }
    return g;
  }
}

Un Map en JavaScript conserva el orden de inserción, y esas tres líneas convierten esa garantía en una política de descarte del menos usado recientemente sin ninguna estructura adicional. El límite importa: una caché sin techo con claves derivadas de valores continuos —una altura en decimales— crece indefinidamente y es una fuga de memoria con otro nombre.

De ahí sale la regla de diseño de cualquier caché de este tipo: la clave tiene que ser discreta. Redondear la altura al entero, o a múltiplos de cuatro, convierte un espacio infinito de claves en uno pequeño sin diferencia visible.

Las cadenas de color

Esta es la que más sorprende cuando aparece en un perfil. Construir una cadena de color con una plantilla parece inocente:

// Una cadena nueva y un analisis CSS por objeto y por fotograma
ctx.fillStyle = `rgba(137, 180, 250, ${p.vida / p.vidaMaxima})`;

Con diez mil partículas son diez mil concatenaciones, diez mil objetos de cadena para el recolector y diez mil análisis del formato de color por fotograma. Hay dos salidas y ambas son fáciles.

Discretizar y precalcular una tabla. El ojo no distingue doscientos cincuenta y seis niveles de opacidad; con treinta y dos sobra.

const NIVELES = 32;
const coloresPorAlfa = Array.from({ length: NIVELES + 1 }, (_, i) =>
  `rgba(137, 180, 250, ${(i / NIVELES).toFixed(3)})`);

const idx = Math.round((p.vida / p.vidaMaxima) * NIVELES);
ctx.fillStyle = coloresPorAlfa[idx];      // cadena ya existente

Usar globalAlpha en lugar de meter el alfa en el color. Asignar un número es mucho más barato que analizar una cadena, y permite dejar fillStyle fijo para todo el grupo.

ctx.fillStyle = '#89b4fa';                 // una sola vez
for (const p of particulas) {
  ctx.globalAlpha = p.vida / p.vidaMaxima; // solo un numero
  ctx.fillRect(p.x, p.y, 3, 3);
}
ctx.globalAlpha = 1;

La segunda versión es la que se usa en producción, y tiene la ventaja añadida de que agrupa: todos los objetos comparten el estilo de relleno y solo varía un escalar.

La basura que genera un bucle de dibujo no se ve como lentitud, se ve como un tirón cada pocos segundos

El coste de crear objetos por fotograma tiene dos partes y solo se suele contar la primera. La primera es el tiempo de reservar memoria, que es pequeño. La segunda es que alguien tiene que recogerla, y el recolector de basura se ejecuta en tu hilo, cuando le conviene a él, y durante ese rato tu bucle no avanza. El síntoma es inconfundible una vez lo has visto: la animación va perfectamente a sesenta fotogramas y cada pocos segundos hay un fotograma que tarda veinte, treinta o cincuenta milisegundos. En una gráfica de tiempo por fotograma son picos periódicos y aislados sobre una línea plana. Como la media sigue siendo excelente, cualquier medición basada en el promedio dice que todo va bien mientras el usuario está viendo tirones. Cómo confirmarlo: en el panel de rendimiento, la pista de memoria del montón dibuja una sierra —sube linealmente, cae en vertical— y cada caída vertical coincide con un pico de tiempo. Si la sierra sube deprisa, estás reservando mucho por fotograma. Dónde está casi siempre: no en los objetos grandes que uno sospecha, sino en cuatro cosas pequeñas repetidas muchísimo. Las cadenas construidas con plantillas, que son objetos nuevos cada vez. Los arrays temporales creados con map o filter dentro del bucle. Los objetos literales devueltos por funciones auxiliares, del tipo return { x, y } en una función que se llama cien mil veces. Y las clausuras creadas por fotograma, que aparecen sin querer al pasar una función anónima a un forEach dentro del bucle interno. Cómo se arregla, en orden de retorno: precalcular las cadenas; recorrer con bucles clásicos en el camino caliente en lugar de con métodos que crean arrays; escribir resultados en objetos que ya existen en lugar de devolver literales; y, solo si sigue haciendo falta, reservar de antemano un depósito de objetos reutilizables. Y una advertencia sobre esa última técnica: el depósito de objetos hace el código notablemente peor de leer, así que reservarlo para el bucle interno de una escena densa y no aplicarlo por sistema. Un editor de diagramas no lo necesita jamás; un sistema de cien mil partículas no funciona sin él.

Fuera del bucle todo lo que se pueda

La lista completa de lo que nunca debería aparecer dentro de la función de dibujo, ordenada por frecuencia con la que se ve:

Dentro del bucle Dónde va
createLinearGradient y createRadialGradient Inicialización o caché con clave discreta
createPattern Inicialización; se anima con setTransform del patrón
Cadenas de color con plantillas Tabla precalculada o globalAlpha
measureText Caché por texto y fuente
new Path2D(cadena) Carga de datos, nunca en el dibujo
getImageData y toDataURL Fuera del bucle siempre
getBoundingClientRect En el redimensionado, no por fotograma
getComputedStyle Al arrancar y al cambiar el tema
document.querySelector Al inicializar, guardando la referencia

Las dos últimas filas merecen una nota porque no son cuestión de basura sino de algo peor: leer geometría o estilos calculados del DOM dentro del bucle puede forzar un recálculo de layout síncrono, que es una de las operaciones más caras que existen en el navegador y que además ocurre justo antes de que el navegador quisiera componer el fotograma.

⚔️ Reto práctico

Coge una escena de partículas que construya la cadena de color por partícula. Grábala treinta segundos en el panel de rendimiento y anota la altura de los picos y su frecuencia. Cambia a la versión con globalAlpha y repite. Además de la mejora de media, verás desaparecer la sierra de la memoria, y ese es el cambio que el usuario percibe.