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

Las tres firmas de drawImage

Dominar el método más versátil del canvas: sus tres formas de llamada, las fuentes que acepta, y el comportamiento con imágenes que aún no han cargado.

⏱ 16 min

drawImage es el único método del canvas con tres firmas distintas y ocho parámetros posibles, y es también el que más trabajo hace: copia píxeles, escala, recorta y compone en una sola llamada. Aceptar como fuente casi cualquier cosa que produzca imagen —incluido otro canvas y un vídeo en reproducción— es lo que lo convierte en la pieza central de las técnicas de caché, sprites y procesamiento.

🎯 Al terminar esta lección sabrás
  • Usar las tres firmas de drawImage y decir qué significan sus parámetros.
  • Enumerar los tipos de fuente aceptados y sus peculiaridades.
  • Manejar correctamente las imágenes que aún no han cargado.
  • Reconocer los casos en que drawImage no dibuja nada y por qué.

Las tres firmas

ctx.drawImage(fuente, dx, dy);
ctx.drawImage(fuente, dx, dy, dAncho, dAlto);
ctx.drawImage(fuente, sx, sy, sAncho, sAlto, dx, dy, dAncho, dAlto);

La primera dibuja la imagen a su tamaño natural con la esquina superior izquierda en (dx, dy).

La segunda la escala para que ocupe el rectángulo de destino. No conserva la proporción: si la das deformada, sale deformada.

La tercera recorta una región del origen y la dibuja en un rectángulo de destino. Los cuatro primeros parámetros describen el recorte en coordenadas de la fuente; los cuatro últimos, el destino en coordenadas del espacio de usuario actual.

Esa asimetría es importante y es fuente de confusión: el rectángulo de origen se mide en píxeles de la imagen original y no se ve afectado por la matriz del contexto; el de destino sí. Si tienes el contexto escalado por la densidad de pantalla, las coordenadas de origen siguen siendo píxeles de la imagen y las de destino son unidades lógicas.

// Recortar la mitad derecha de una imagen de 800x600 y dibujarla a 200x150
ctx.drawImage(img,
  400, 0, 400, 600,     // origen: mitad derecha, en pixeles de la imagen
  20, 20, 200, 150);    // destino: en unidades del contexto

Las fuentes aceptadas

drawImage acepta cualquier CanvasImageSource, que engloba:

  • HTMLImageElement, es decir, una <img> o un new Image().
  • SVGImageElement.
  • HTMLVideoElement: dibuja el fotograma actual del vídeo.
  • HTMLCanvasElement: otro canvas.
  • ImageBitmap.
  • OffscreenCanvas.
  • VideoFrame, de la API de códecs web.

De esa lista, tres merecen comentario.

Un vídeo dibuja el fotograma que se esté mostrando en ese instante. Es la puerta de entrada al procesamiento de vídeo y de cámara en el navegador: cada fotograma que dibujas en el canvas puedes analizarlo o modificarlo.

Otro canvas es la base de la técnica de caché: dibujas una vez algo costoso en un canvas auxiliar y después lo copias tantas veces como quieras con una operación barata.

Un ImageBitmap es la fuente más eficiente de todas, porque ya está decodificada y en un formato listo para la GPU. Merece su propia lección.

Cuando no dibuja nada

drawImage es de los pocos métodos del canvas que puede lanzar excepciones, y también de los que fallan en silencio. Conviene tener claros los cuatro casos.

La imagen no ha cargado. Si el estado de la imagen es “no disponible” —img.complete es falso o naturalWidth es cero— la llamada no hace nada y no lanza. Es el caso más frecuente y el más desconcertante, porque el código es correcto y no pasa nada.

Un rectángulo con ancho o alto cero. No dibuja y no lanza.

Un valor no finito en cualquier parámetro. La llamada se ignora silenciosamente, igual que en el resto de la API.

El rectángulo de origen se sale de la imagen. Aquí sí hay dos comportamientos: si el recorte queda completamente fuera, se lanza un IndexSizeError; si queda parcialmente fuera, se recorta a la intersección y se dibuja lo que hay, escalando en consecuencia.

La defensa completa cabe en una función:

function dibujarSeguro(ctx, img, ...args) {
  if (!img) return false;
  const listo = img instanceof HTMLImageElement
    ? img.complete && img.naturalWidth > 0
    : true;
  if (!listo) return false;
  try { ctx.drawImage(img, ...args); return true; }
  catch { return false; }
}

Cargar una imagen bien

El patrón correcto usa promesas y contempla el error:

function cargarImagen(url) {
  return new Promise((resolver, rechazar) => {
    const img = new Image();
    img.onload = () => resolver(img);
    img.onerror = () => rechazar(new Error('No se pudo cargar ' + url));
    img.src = url;
  });
}

const logo = await cargarImagen('/logo.png');
ctx.drawImage(logo, 20, 20);

Dos detalles que hay que fijar. El primero: asigna src después de los manejadores. Con una imagen ya en caché, el navegador puede resolver la carga de forma síncrona en algunos motores, y un manejador asignado después no se dispararía.

El segundo: para imágenes de otro origen que vayas a leer píxeles después, hay que pedir CORS antes de asignar el src:

const img = new Image();
img.crossOrigin = 'anonymous';   // ANTES de src
img.src = 'https://otro-dominio.example/foto.jpg';

Sin eso, dibujar la imagen funciona, pero el canvas queda contaminado y cualquier lectura posterior —getImageData, toDataURL, toBlob— lanza un SecurityError. Es una protección contra la exfiltración de contenido de otro origen y no tiene ninguna forma de rodearse desde el cliente: el servidor tiene que enviar la cabecera adecuada.

El coste de drawImage varía en dos órdenes de magnitud según cómo lo llames

drawImage puede ser la operación más barata del canvas o una de las más caras, y la diferencia depende de detalles que no se ven en el código. El camino rápido es una copia de bloque: cuando el destino tiene el mismo tamaño que el origen, la posición de destino es entera, la matriz del contexto no tiene rotación ni sesgo, y no hay filtros ni composición exótica, el navegador puede transferir memoria sin tocar ni un píxel. Sale una operación de coste prácticamente nulo. En cuanto rompes cualquiera de esas condiciones, entra el camino de remuestreo: cada píxel de destino tiene que interpolar cuatro o más píxeles de origen. La diferencia medida en escenas reales es de entre diez y cien veces. Y aquí está lo que hace que esto importe tanto: es facilísimo caer del camino rápido sin darte cuenta. Un contexto escalado por la densidad de pantalla ya rompe la condición de escala 1, con lo que todos tus drawImage van por el camino lento aunque tu código hable de tamaños idénticos. La defensa correcta es preparar los recursos ya a la escala física: si vas a dibujar un sprite en un contexto escalado por 2, genera o carga ese sprite al doble de resolución y dibújalo con un tamaño de destino que sea exactamente su tamaño natural dividido por 2… lo cual sigue sin ser escala 1. La única forma de conseguir escala 1 real es dibujar con la matriz identidad, en coordenadas del búfer, y calcular tú las posiciones. Es lo que hacen los motores 2D serios: mantienen el contexto sin escalar y multiplican las posiciones por la densidad al colocar. Es menos cómodo y es la diferencia entre sesenta y veinte fotogramas por segundo en una escena con muchos sprites. La regla operativa para el resto de los casos: redondea siempre la posición de destino a entero, que además de ser más rápido produce sprites más nítidos.

Un ejemplo con ajuste de proporción

Un caso constante en la práctica es dibujar una imagen dentro de una caja conservando su relación de aspecto, con los dos comportamientos de object-fit:

/** modo: 'contain' cabe entera con bandas; 'cover' llena y recorta. */
function dibujarAjustado(ctx, img, x, y, w, h, modo = 'cover') {
  const iw = img.naturalWidth ?? img.width;
  const ih = img.naturalHeight ?? img.height;
  const escala = modo === 'cover'
    ? Math.max(w / iw, h / ih)
    : Math.min(w / iw, h / ih);
  const dw = iw * escala, dh = ih * escala;
  const dx = x + (w - dw) / 2, dy = y + (h - dh) / 2;

  if (modo === 'cover') {
    // Recortar en origen es mejor que confiar en el clip: menos pixeles movidos
    const sw = w / escala, sh = h / escala;
    const sx = (iw - sw) / 2, sy = (ih - sh) / 2;
    ctx.drawImage(img, sx, sy, sw, sh, x, y, w, h);
  } else {
    ctx.drawImage(img, dx, dy, dw, dh);
  }
}

Fíjate en que el modo cover se resuelve recortando en el origen en lugar de dibujar de más y recortar con clip. Es más rápido porque el navegador solo remuestrea los píxeles que van a verse, y evita tener que gestionar una región de recorte.