Recorte y sprite sheets
Usar la firma de nueve parámetros para dibujar desde un atlas de imágenes, y entender por qué agrupar recursos en una sola textura sigue siendo la técnica correcta.
Un sprite sheet es una sola imagen que contiene muchas, y dibujar desde ella es exactamente para lo que existe la firma larga de drawImage. La técnica viene de los años ochenta y sigue siendo correcta por razones que han cambiado con el tiempo: antes era por el número de peticiones de red, ahora es por el coste de cambiar de textura y por el consumo de memoria de las imágenes sueltas.
- Dibujar un fotograma concreto de un atlas con la firma de nueve parámetros.
- Construir un índice de sprites por nombre y usarlo en un bucle de animación.
- Explicar las tres razones por las que un atlas sigue siendo mejor que N imágenes.
- Evitar el sangrado de bordes entre celdas contiguas.
Dibujar un fotograma
Con una rejilla regular, el cálculo es aritmética:
function dibujarCelda(ctx, hoja, indice, columnas, ancho, alto, dx, dy) {
const col = indice % columnas;
const fil = Math.floor(indice / columnas);
ctx.drawImage(hoja,
col * ancho, fil * alto, ancho, alto, // origen: la celda
dx, dy, ancho, alto); // destino
}
Con un atlas empaquetado —celdas de tamaños distintos colocadas por un algoritmo de empaquetado— hace falta un índice, que las herramientas de empaquetado generan como JSON:
const atlas = {
imagen: null,
marcos: {
'nave-1': { x: 0, y: 0, w: 48, h: 48 },
'nave-2': { x: 48, y: 0, w: 48, h: 48 },
'explota-1':{ x: 96, y: 0, w: 64, h: 64 },
'explota-2':{ x: 160, y: 0, w: 64, h: 64 },
},
};
function sprite(ctx, nombre, dx, dy, escala = 1) {
const m = atlas.marcos[nombre];
if (!m) return;
ctx.drawImage(atlas.imagen, m.x, m.y, m.w, m.h,
Math.round(dx), Math.round(dy), m.w * escala, m.h * escala);
}
Los Math.round de la posición de destino no son cosméticos: mantienen el camino rápido de copia y producen sprites nítidos.
Animación por fotogramas
Una animación de sprites es un índice que avanza con el tiempo. La forma correcta es basarla en tiempo transcurrido y no en número de fotogramas, para que la velocidad no dependa de la tasa de refresco:
class Animacion {
constructor(marcos, fps = 12, bucle = true) {
this.marcos = marcos; // ['nave-1', 'nave-2', ...]
this.duracion = 1000 / fps;
this.bucle = bucle;
this.transcurrido = 0;
this.terminada = false;
}
actualizar(dt) {
if (this.terminada) return;
this.transcurrido += dt;
const total = this.duracion * this.marcos.length;
if (!this.bucle && this.transcurrido >= total) {
this.transcurrido = total - 1;
this.terminada = true;
}
}
get marco() {
const i = Math.floor(this.transcurrido / this.duracion) % this.marcos.length;
return this.marcos[i];
}
}
Con eso, dibujar es una línea y la velocidad es independiente del dispositivo:
let anterior = performance.now();
function bucle(ahora) {
const dt = ahora - anterior; anterior = ahora;
anim.actualizar(dt);
ctx.clearRect(0, 0, W, H);
sprite(ctx, anim.marco, x, y, 2);
requestAnimationFrame(bucle);
}
requestAnimationFrame(bucle);
Por qué el atlas sigue ganando
Las tres razones actuales, en orden de importancia.
El cambio de textura cuesta. Un canvas acelerado agrupa las llamadas de dibujo en lotes, y el criterio de agrupación es la textura de origen. Cien drawImage desde la misma imagen se resuelven en un lote; cien desde cien imágenes distintas se resuelven en cien lotes, cada uno con su cambio de estado en la GPU. La diferencia es grande y crece con el número de elementos.
La memoria por imagen tiene sobrecoste. Cada imagen decodificada tiene su propia asignación, y las GPU redondean las texturas a potencias de dos en algunos caminos. Doscientos iconos de 24 por 24 sueltos ocupan bastante más que un atlas de 512 por 512 que los contiene todos.
Menos peticiones y mejor compresión. Sigue siendo cierto aunque HTTP/2 haya reducido el coste de las peticiones múltiples: un solo fichero comprime mejor que doscientos pequeños, porque el compresor ve más redundancia.
Lo que ha cambiado es que ya no es cierto que las peticiones múltiples sean el factor dominante. Con HTTP/2 y HTTP/3 el coste por petición es bajo, así que si tus recursos son pocos y grandes, el atlas aporta menos. Sigue aportando para muchos recursos pequeños, que es exactamente el caso de los iconos y los sprites.
Este es el fallo característico de los atlas y su diagnóstico es antipático porque solo aparece a algunos tamaños. El síntoma: al dibujar un sprite, aparece una línea de un píxel del sprite vecino pegada a uno de sus bordes. Cambias la escala y desaparece; cambias la posición y vuelve. La causa es la interpolación: cuando el rectángulo de origen no cae exactamente sobre la rejilla de píxeles del atlas —lo que ocurre en cuanto hay una escala fraccionaria, y la densidad de pantalla ya la introduce— el filtro bilineal muestrea fuera del rectángulo que pediste, y lo que hay fuera es el sprite de al lado. Hay tres remedios y conviene aplicar los tres. Uno: relleno de separación. Deja al menos un píxel transparente entre celdas al empaquetar el atlas. Es lo mínimo y a menudo no basta con escalas grandes. Dos: extensión del borde. En lugar de dejar el hueco transparente, duplica la fila y la columna de borde de cada sprite hacia fuera. Así, cuando el filtro muestrea de más, muestrea el mismo color del borde y no se nota nada. Es lo que hacen todos los empaquetadores serios, y la opción se suele llamar extrusión o bleed. Tres: encoger medio píxel el rectángulo de origen. Sumar 0,5 a sx y sy y restar 1 a sw y sh garantiza que el filtro nunca salga de la celda, a costa de perder medio píxel de contenido en cada borde. Es un parche que funciona cuando no controlas el atlas. Y hay un cuarto camino, el más limpio cuando tienes el recurso original: no uses atlas para sprites que se van a escalar mucho; usa ImageBitmap individuales, que no tienen vecinos de los que sangrar. El atlas gana en número de elementos, no en calidad de escalado.
Generar un atlas en tiempo de ejecución
Una técnica muy útil y poco conocida: construir el atlas al arrancar, en un canvas fuera de pantalla, a partir de formas dibujadas por código. Con eso obtienes las ventajas del atlas sin tener que producir ningún fichero de imagen.
function construirAtlas(definiciones, lado = 64, dpr = 1) {
const columnas = Math.ceil(Math.sqrt(definiciones.length));
const filas = Math.ceil(definiciones.length / columnas);
const hoja = document.createElement('canvas');
hoja.width = columnas * lado * dpr;
hoja.height = filas * lado * dpr;
const c = hoja.getContext('2d');
c.scale(dpr, dpr);
const marcos = {};
definiciones.forEach((def, i) => {
const col = i % columnas, fil = Math.floor(i / columnas);
const x = col * lado, y = fil * lado;
c.save();
c.translate(x + lado / 2, y + lado / 2); // cada icono se dibuja centrado
def.dibujar(c, lado * 0.4);
c.restore();
marcos[def.nombre] = { x: x * dpr, y: y * dpr, w: lado * dpr, h: lado * dpr };
});
return { imagen: hoja, marcos, dpr };
}
const atlas = construirAtlas([
{ nombre: 'circulo', dibujar: (c, r) => {
c.fillStyle = '#89b4fa'; c.beginPath(); c.arc(0, 0, r, 0, Math.PI * 2); c.fill(); } },
{ nombre: 'cuadrado', dibujar: (c, r) => {
c.fillStyle = '#a6e3a1'; c.fillRect(-r, -r, r * 2, r * 2); } },
{ nombre: 'triangulo', dibujar: (c, r) => {
c.fillStyle = '#f9e2af'; c.beginPath();
c.moveTo(0, -r); c.lineTo(r, r); c.lineTo(-r, r); c.closePath(); c.fill(); } },
], 64, window.devicePixelRatio || 1);
A partir de ahí, dibujar diez mil marcadores en un mapa son diez mil drawImage desde una sola textura, en lugar de diez mil construcciones de ruta y diez mil rellenos. La diferencia de rendimiento en escenas densas es de un orden de magnitud, y esta es una de las optimizaciones más rentables que existen en canvas.
Genera un atlas de diez marcadores de colores con la función anterior y compara el tiempo de dibujar veinte mil marcadores desde el atlas frente a dibujarlos con arc y fill. Después prueba una tercera variante: el atlas pero sin redondear las posiciones de destino. Los tres números explican la lección entera.