El canvas de caché para lo que se repite
Dibujar una vez y copiar muchas: qué merece caché, con qué resolución y márgenes, cómo agrupar en un atlas y cuándo la caché sale más cara que dibujar.
Copiar un mapa de bits es una de las operaciones más rápidas que sabe hacer una GPU, y construir una figura con veinte curvas y una sombra es de las más lentas. Cuando esa figura aparece quinientas veces en la escena, la conclusión es inmediata: dibújala una vez en un búfer aparte y copia el resultado. Lo que no es inmediato es a qué resolución guardarla, cuánto margen dejarle, cuándo invalidarla y en qué momento la caché deja de compensar.
- Decidir qué elementos merecen caché con un criterio cuantitativo.
- Dimensionar la caché con la densidad y los márgenes correctos.
- Agrupar varias figuras en un atlas y dibujarlas por recorte.
- Reconocer los tres casos en que la caché empeora el resultado.
Qué merece caché
La regla es una división: caché merece la pena cuando el coste de dibujar la figura, multiplicado por las veces que aparece, supera el coste de copiar un mapa de bits multiplicado por lo mismo, más el coste de construir la caché una vez. Como copiar es casi gratis, en la práctica se reduce a dos preguntas: ¿es cara de dibujar? ¿aparece muchas veces o en muchos fotogramas?
| Candidato | Merece caché | Motivo |
|---|---|---|
| Etiquetas de texto repetidas | Sí, mucho | fillText es de lo más caro de la API |
| Iconos y marcadores de mapa | Sí | Rutas complejas repetidas miles de veces |
| Figuras con sombra o filtro | Sí, siempre | La sombra se calcula una vez en lugar de por objeto |
| Rejilla, ejes, fondo estático | Sí | Muchas líneas que no cambian entre fotogramas |
| Un rectángulo de color plano | No | Copiar cuesta lo mismo o más que rellenarlo |
| Figuras que cambian de forma cada fotograma | No | La caché se invalida antes de amortizarse |
El caso del texto merece subrayarse porque es donde la mejora es más espectacular. Una etiqueta cacheada como mapa de bits se copia en microsegundos; la misma etiqueta con fillText implica seleccionar fuente, dar forma al texto y rasterizar glifos en cada aparición. En un gráfico con dos mil etiquetas, la caché es la diferencia entre veinte fotogramas por segundo y ciento veinte.
La infraestructura básica ya la tienes en OffscreenCanvas como capa de caché; lo que sigue es lo que hay que añadirle para que funcione en una escena real.
El tamaño correcto
Tres decisiones sobre el tamaño, y las tres se equivocan de la misma forma la primera vez.
La densidad. La caché tiene que estar a la resolución física a la que se va a copiar, o el resultado sale borroso. Eso significa multiplicar por el mismo devicePixelRatio que usa el canvas principal y dibujar dentro con la matriz correspondiente.
El margen. Una figura dibujada exactamente en su caja envolvente pierde el píxel de antialiasing del borde, y si tiene trazo pierde la mitad del grosor. Con sombra, el recorte es brutal. El margen tiene que cubrir la mitad del trazo más el desplazamiento de la sombra más su desenfoque.
El punto de anclaje. Si guardas el margen, el origen de la figura ya no está en la esquina de la caché. Guardarlo junto al mapa de bits evita tener que recordarlo en cada copia.
class Sprite {
constructor({ ancho, alto, margen = 0, dpr = 1, pintar }) {
this.dpr = dpr;
this.margen = margen;
this.ancho = ancho + margen * 2; // en unidades logicas
this.alto = alto + margen * 2;
this.lienzo = new OffscreenCanvas(
Math.ceil(this.ancho * dpr), Math.ceil(this.alto * dpr));
const c = this.lienzo.getContext('2d');
c.setTransform(dpr, 0, 0, dpr, 0, 0);
c.translate(margen, margen); // el origen logico queda en 0,0
pintar(c);
}
// x e y son la posicion del origen logico de la figura
copiarEn(ctx, x, y) {
ctx.drawImage(this.lienzo,
0, 0, this.lienzo.width, this.lienzo.height,
x - this.margen, y - this.margen, this.ancho, this.alto);
}
}
Y su uso, con una figura que sin caché costaría una sombra por objeto:
const dpr = window.devicePixelRatio || 1;
const marcador = new Sprite({
ancho: 28, alto: 36, margen: 12, dpr,
pintar: c => {
c.shadowColor = 'rgba(0,0,0,0.45)';
c.shadowBlur = 8;
c.shadowOffsetY = 3;
c.fillStyle = '#f38ba8';
c.beginPath();
c.moveTo(14, 36);
c.bezierCurveTo(0, 20, 0, 10, 6, 5);
c.arc(14, 8, 8, Math.PI, 0);
c.bezierCurveTo(28, 10, 28, 20, 14, 36);
c.fill();
},
});
function dibujar(ctx, puntos) {
for (const p of puntos) marcador.copiarEn(ctx, p.x - 14, p.y - 36);
}
Fíjate en que la sombra se dibujó una vez, dentro de la caché. Diez mil marcadores con sombra pasan de ser imposibles a costar diez mil copias de un mapa de bits pequeño.
La cuenta de memoria hay que hacerla siempre: cada caché ocupa ancho por alto por densidad al cuadrado por cuatro bytes. Un sprite de 40 por 48 a densidad 3 son unos 69 kilobytes; cien sprites distintos, casi siete megabytes. En un móvil eso importa.
El atlas
Cuando hay muchos sprites pequeños, tenerlos en canvas separados tiene un coste que no es de memoria sino de conmutación: cada copia desde una fuente distinta puede obligar al motor a cambiar la textura activa en la GPU. Agruparlos en un solo lienzo elimina ese cambio.
class Atlas {
constructor(dpr = 1, anchoMax = 2048) {
this.dpr = dpr;
this.anchoMax = anchoMax;
this.lienzo = new OffscreenCanvas(anchoMax * dpr, 64 * dpr);
this.ctx = this.lienzo.getContext('2d');
this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
this.x = 0; this.y = 0; this.altoFila = 0;
this.entradas = new Map();
}
anadir(clave, ancho, alto, pintar) {
if (this.entradas.has(clave)) return this.entradas.get(clave);
if (this.x + ancho > this.anchoMax) { // salto de linea
this.x = 0;
this.y += this.altoFila + 1;
this.altoFila = 0;
}
const necesario = (this.y + alto + 1) * this.dpr;
if (necesario > this.lienzo.height) this.crecer(necesario);
this.ctx.save();
this.ctx.translate(this.x, this.y);
pintar(this.ctx);
this.ctx.restore();
const entrada = { x: this.x, y: this.y, ancho, alto };
this.entradas.set(clave, entrada);
this.x += ancho + 1; // un pixel de separacion
this.altoFila = Math.max(this.altoFila, alto);
return entrada;
}
crecer(alturaNecesaria) {
const copia = new OffscreenCanvas(this.lienzo.width, this.lienzo.height);
copia.getContext('2d').drawImage(this.lienzo, 0, 0);
this.lienzo.height = Math.ceil(alturaNecesaria * 1.5);
this.ctx.setTransform(1, 0, 0, 1, 0, 0);
this.ctx.drawImage(copia, 0, 0);
this.ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
}
copiar(ctx, clave, x, y) {
const e = this.entradas.get(clave);
if (!e) return;
const d = this.dpr;
ctx.drawImage(this.lienzo,
e.x * d, e.y * d, e.ancho * d, e.alto * d,
x, y, e.ancho, e.alto);
}
}
El píxel de separación entre entradas no es una manía: sin él, el filtrado bilineal al copiar puede tomar píxeles del sprite vecino y aparece una línea de color en el borde. Con imágenes escaladas es un fallo garantizado.
El uso típico, con etiquetas de texto que son el caso más rentable:
const atlas = new Atlas(dpr);
const ctxMedida = document.createElement('canvas').getContext('2d');
ctxMedida.font = '600 13px system-ui';
function etiqueta(texto) {
const ancho = Math.ceil(ctxMedida.measureText(texto).width) + 2;
return atlas.anadir(texto, ancho, 18, c => {
c.font = '600 13px system-ui';
c.textBaseline = 'middle';
c.fillStyle = '#cdd6f4';
c.fillText(texto, 1, 9);
});
}
for (const p of puntos) { etiqueta(p.nombre); } // se construye una vez
function dibujar(ctx, puntos) {
for (const p of puntos) atlas.copiar(ctx, p.nombre, p.x + 6, p.y - 9);
}
Dos costes de la caché que no aparecen en ningún tutorial y que decidirán si tu implementación es un éxito o una fuente de quejas. El primero es la resolución fija. Un mapa de bits guardado a una escala determinada y copiado a otra se interpola: al ampliar sale borroso, al reducir sale con dientes de sierra por submuestreo. En una escena con zoom, la caché construida al nivel inicial se degrada visiblemente en cuanto el usuario amplía, y el resultado es la queja característica de “los iconos se ven mal al acercar”. La solución no es cachear a la máxima resolución posible, porque eso multiplica la memoria por el cuadrado del factor y hace que la mayoría del tiempo estés copiando cuatro veces más píxeles de los necesarios. La solución es una política escalonada por potencias de dos: se cachea al nivel de zoom redondeado a la potencia de dos superior, se reutiliza mientras el zoom se mueve dentro de ese escalón, y se reconstruye al cruzar un escalón. Con eso el error de escala nunca pasa del doble, la memoria está acotada y las reconstrucciones son pocas. Y para el zoom continuo con inercia, la regla adicional que salva el rendimiento: no reconstruyas mientras el gesto está en marcha; deja que se vea escalado durante el gesto y reconstruye al soltar, que es exactamente lo que hacen las aplicaciones de mapas. El segundo coste es más sutil y muerde justo cuando peor viene. La primera vez que copias desde un canvas de caché recién creado, el motor tiene que subir esa imagen a la memoria de la GPU, y esa transferencia es síncrona y proporcional al tamaño. Es decir: el fotograma en el que construyes una caché grande es también el fotograma en el que pagas su subida, y ambos costes se suman en el peor momento posible, que suele ser justo cuando el usuario acaba de interactuar. El síntoma es un tirón al empezar a arrastrar o al terminar un zoom, seguido de fluidez perfecta. Hay dos mitigaciones y conviene aplicar las dos. Construir las cachés durante el arranque o en un momento ocioso, con requestIdleCallback, en lugar de bajo demanda dentro del bucle. Y calentarlas: después de construir una caché, hacer una copia de un píxel de ella a un canvas cualquiera fuerza la subida en ese instante en lugar de en el primer fotograma que la necesite. Son dos líneas y eliminan un tirón que de otro modo es imposible de explicar.
Cuándo la caché empeora las cosas
Tres situaciones en las que aplicar esta técnica es un retroceso, y conviene reconocerlas antes de escribir el código.
Cuando la figura es más barata que la copia. Un círculo de color plano de ocho píxeles se rellena más rápido de lo que se copia un mapa de bits con su interpolación. Por debajo de cierto tamaño y complejidad, la caché es puro coste.
Cuando la invalidación es constante. Si el contenido cambia en cada fotograma, cachearlo significa reconstruir la caché en cada fotograma y además copiarla: haces todo el trabajo anterior más uno nuevo. La caché solo tiene sentido cuando el número de reconstrucciones es mucho menor que el de usos.
Cuando la memoria no da. En un móvil de gama media, unos pocos canvas de pantalla completa a densidad 3 agotan el presupuesto de memoria gráfica y el navegador empieza a descartar búferes, con lo que aparecen canvas en blanco de forma aleatoria. Los límites concretos están en límites de tamaño y memoria, y la regla práctica es no tener más de dos o tres capas de tamaño completo.
La forma de decidir sin discutir es medir: dibuja la escena con caché y sin ella, con el número de objetos real, y compara el tiempo por fotograma. La diferencia suele ser de un orden de magnitud en un sentido o en otro, así que no hace falta precisión para acertar.