Saltar al contenido

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.

Dibujar un fotograma

Con una rejilla regular, el cálculo es aritmética:

JS
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:

JS
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:

JS
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:

JS
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.

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.

JS
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.