wandres.dev
IMÁGENES · drawImage y sus tres formas

createImageBitmap y sus opciones

Usar el objeto de imagen optimizado del canvas: recorte, reescalado, orientación, alfa y espacio de color en la propia decodificación, y liberación explícita.

⏱ 17 min

createImageBitmap produce el único tipo de imagen de la plataforma pensado específicamente para dibujarse rápido: ya decodificada, en un formato apto para la GPU, transferible entre hilos y con liberación explícita. Sus opciones permiten recortar, reescalar, corregir la orientación y elegir el tratamiento del alfa durante la decodificación, que es mucho más barato que hacerlo después.

🎯 Al terminar esta lección sabrás
  • Crear un ImageBitmap desde cualquier fuente de imagen y desde un blob.
  • Usar las opciones de recorte, reescalado, orientación, alfa y espacio de color.
  • Explicar por qué un ImageBitmap se dibuja más rápido que una HTMLImageElement.
  • Liberar la memoria con close() y saber por qué es la única API que lo exige.

Qué es y por qué existe

const bitmap = await createImageBitmap(fuente, opciones);

Un ImageBitmap es una imagen ya decodificada, sin ningún estado de carga y sin ninguna relación con el DOM. Frente a una HTMLImageElement, gana en tres cosas.

No hay decodificación en el momento del dibujo. Una <img> puede llegar al drawImage con los datos comprimidos aún sin descomprimir, y entonces el navegador decodifica en ese instante, en el hilo principal, con un tirón proporcional al tamaño. Un ImageBitmap ya está listo.

Se puede transferir a un worker. Es un objeto transferible: postMessage puede moverlo sin copiar los píxeles.

Se puede liberar. Tiene un método close() que suelta la memoria de inmediato, sin esperar al recolector.

Las fuentes admitidas son las mismas de drawImage más dos importantes: un Blob —lo que devuelve fetch con datos de imagen— y un ImageData.

const respuesta = await fetch('/foto.jpg');
const blob = await respuesta.blob();
const bitmap = await createImageBitmap(blob);
ctx.drawImage(bitmap, 0, 0);

Ese camino evita por completo el elemento <img> y su ciclo de carga, y la decodificación ocurre fuera del hilo principal en los navegadores actuales.

Recorte en la creación

Hay una variante con cuatro parámetros más que recorta durante la decodificación:

const bitmap = await createImageBitmap(fuente, sx, sy, sAncho, sAlto, opciones);

La ventaja frente a recortar con drawImage es que el bitmap resultante solo contiene la región recortada, así que ocupa menos memoria. En un atlas grande del que solo necesitas unas celdas, la diferencia es real.

Una peculiaridad útil: el rectángulo de recorte puede salirse de la imagen, y las zonas que quedan fuera se rellenan con transparente. Eso permite crear un bitmap con margen alrededor de un sprite sin manipular la imagen original.

Las opciones

const bitmap = await createImageBitmap(fuente, {
  imageOrientation: 'from-image',    // 'from-image' | 'flipY' | 'none'
  premultiplyAlpha: 'default',       // 'none' | 'premultiply' | 'default'
  colorSpaceConversion: 'default',   // 'none' | 'default'
  resizeWidth: 256,
  resizeHeight: 256,
  resizeQuality: 'high',             // 'pixelated' | 'low' | 'medium' | 'high'
});

imageOrientation decide qué hacer con los metadatos de orientación de un JPEG. Con 'from-image' se respeta la orientación indicada en los metadatos, que es lo que quieres para fotos hechas con móvil, donde la imagen está girada y solo un dato de la cabecera dice cómo. Con 'flipY' se voltea verticalmente, que es lo que necesitan las texturas de WebGL. Con 'none' se ignora la orientación y se toman los píxeles tal cual.

premultiplyAlpha controla si los canales de color vienen ya multiplicados por el alfa. El valor por defecto deja decidir al navegador, que elige lo más eficiente. Pedir 'none' es lo correcto cuando vas a leer los píxeles y necesitas los valores de color reales.

colorSpaceConversion con 'none' desactiva la conversión al espacio de color del contexto, lo que importa cuando procesas la imagen numéricamente y quieres los valores originales del fichero.

resizeWidth, resizeHeight y resizeQuality reescalan durante la decodificación. Esta es la opción de mayor impacto práctico y la que menos se usa.

Reescalar al decodificar

Cargar una foto de doce megapíxeles para mostrarla como una miniatura de 200 píxeles es un desperdicio de 48 MB de memoria y de un tirón de decodificación completo. Con resizeWidth el navegador puede decodificar directamente a la escala pedida, lo que en varios formatos es mucho más barato que decodificar entero y reducir después.

async function miniatura(blob, lado = 200) {
  return createImageBitmap(blob, {
    resizeWidth: lado,
    resizeHeight: lado,
    resizeQuality: 'high',
  });
}

Ojo: resizeWidth y resizeHeight no conservan la proporción. Si solo especificas uno, el otro se calcula manteniendo la relación de aspecto; si especificas los dos, la imagen se deforma. Para un ajuste tipo cover hay que calcular las dimensiones antes:

async function miniaturaProporcional(blob, ladoMax) {
  const previo = await createImageBitmap(blob);
  const escala = ladoMax / Math.max(previo.width, previo.height);
  const w = Math.round(previo.width * escala);
  const h = Math.round(previo.height * escala);
  previo.close();                       // liberar el grande cuanto antes
  return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h,
                                   resizeQuality: 'high' });
}

Esa doble decodificación parece derrochadora y sigue siendo mucho mejor que mantener la imagen completa en memoria, sobre todo si el Blob viene de un fichero que el usuario acaba de seleccionar y hay decenas de ellos.

resizeQuality: 'pixelated' usa vecino más próximo, que es lo que quieres para escalar pixel art sin filtrado.

close() es la única liberación explícita de memoria gráfica de la plataforma, y por eso importa

De todos los recursos gráficos de la web, ImageBitmap es el único que expone un método para liberarlo. Ni las imágenes, ni los canvas, ni los patrones tienen equivalente: se liberan cuando el recolector decide. Que exista close() no es un capricho de la especificación; es un reconocimiento de que estos objetos guardan mucha memoria fuera del heap y de que el recolector, que solo ve el objeto envoltorio de unas decenas de bytes, no tiene ninguna urgencia por recogerlos. Un ImageBitmap de una foto de doce megapíxeles pesa 48 MB reales y aparece como un objeto minúsculo en un snapshot de memoria. Si generas bitmaps en un bucle —al procesar una galería, al construir teselas de un mapa, al decodificar fotogramas de vídeo— y no los cierras, el consumo real crece sin que el gráfico de memoria de JavaScript se mueva, hasta que el navegador degrada el canvas a software por falta de memoria de vídeo o mata la pestaña. La disciplina es la de un recurso de sistema, no la de un objeto de JavaScript: el que crea un ImageBitmap es responsable de cerrarlo, y si lo transfiere a un worker, el receptor pasa a serlo. Un try/finally alrededor del uso es lo correcto cuando el ámbito es local. Y un detalle que sorprende: transferir un bitmap con postMessage lo cierra automáticamente en el emisor, porque la transferencia mueve la propiedad. Intentar dibujar con él después lanza un error, lo cual en este caso es bueno: es de las pocas veces que el canvas avisa.

Un cargador con caché y límite

Juntando todo, este es el patrón que usa una aplicación seria:

class CacheDeBitmaps {
  constructor(maximo = 60) { this.max = maximo; this.mapa = new Map(); }

  async obtener(url, opciones) {
    const clave = url + JSON.stringify(opciones ?? {});
    const existente = this.mapa.get(clave);
    if (existente) {
      this.mapa.delete(clave);            // reinsertar = mas reciente
      this.mapa.set(clave, existente);
      return existente;
    }
    const promesa = fetch(url)
      .then(r => { if (!r.ok) throw new Error(r.status); return r.blob(); })
      .then(b => createImageBitmap(b, opciones));
    this.mapa.set(clave, promesa);
    this.podar();
    return promesa;
  }

  podar() {
    while (this.mapa.size > this.max) {
      const vieja = this.mapa.keys().next().value;
      const p = this.mapa.get(vieja);
      this.mapa.delete(vieja);
      Promise.resolve(p).then(b => b.close?.()).catch(() => {});
    }
  }

  vaciar() {
    for (const p of this.mapa.values()) {
      Promise.resolve(p).then(b => b.close?.()).catch(() => {});
    }
    this.mapa.clear();
  }
}

Tres detalles. Se guarda la promesa, no el bitmap, para que dos peticiones simultáneas de la misma imagen no decodifiquen dos veces. El Map reinsertado implementa expulsión por uso menos reciente sin estructura adicional. Y la poda cierra los bitmaps expulsados, que es el punto entero de tener una caché con techo en lugar de dejar que crezca.