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.
El gradiente que se crea sesenta veces por segundo
// El clasico: un gradiente nuevo por objeto y por fotogramafunction 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 inicializarconst gradienteBarra = ctx.createLinearGradient(0, 0, 0, 1);gradienteBarra.addColorStop(0, '#89b4fa');gradienteBarra.addColorStop(1, '#1e66f5');// En cada fotogramafunction 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 fotogramactx.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 existenteUsar 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 vezfor (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.
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.