wandres.dev
COMPOSICIÓN · globalCompositeOperation

Los operadores de Porter-Duff

Recorrer los doce operadores de composición clásicos, entender la lógica que los organiza y saber cuál elegir para cada efecto de máscara o recorte.

⏱ 17 min

En 1984, Thomas Porter y Tom Duff publicaron un artículo que resolvió de una vez el problema de combinar imágenes con transparencia, enumerando de forma exhaustiva las maneras en que dos capas pueden ocuparse mutuamente. Cuarenta años después, sus doce operadores son los mismos que expone el canvas, y su lógica es regular una vez ves de dónde salen.

🎯 Al terminar esta lección sabrás
  • Enumerar los operadores de Porter-Duff que el canvas implementa.
  • Deducir el comportamiento de un operador a partir de su nombre.
  • Aplicar destination-out y destination-in para máscaras y borrados.
  • Usar lighter para efectos aditivos de luz y partículas.

La lógica de los nombres

Los operadores se organizan en un esquema que hace innecesario memorizarlos. La primera palabra dice qué capa sobrevive; el sufijo dice dónde.

  • source-*: sobrevive el origen, lo que estás dibujando.
  • destination-*: sobrevive el destino, lo que ya había.
  • -over: en toda su extensión, encima de la otra.
  • -in: solo donde la otra capa también está.
  • -out: solo donde la otra capa no está.
  • -atop: en la extensión de la otra capa.

Con esas seis piezas se generan ocho operadores, y a ellos se suman cuatro casos especiales.

Operador Qué queda
source-over Origen encima del destino. Por defecto.
source-in Origen solo donde hay destino. El destino desaparece.
source-out Origen solo donde no hay destino.
source-atop Origen recortado a la forma del destino, sobre el destino.
destination-over Destino encima del origen: el origen queda debajo.
destination-in Destino solo donde hay origen.
destination-out Destino solo donde no hay origen: el origen borra.
destination-atop Destino recortado a la forma del origen, sobre el origen.
lighter Suma de ambos: los colores se acumulan.
copy Solo el origen; el destino se descarta entero.
xor Ambos, salvo donde se solapan, que queda transparente.
source-over con alfa El caso general de la mezcla.

Los tres que se usan de verdad

De los doce, tres resuelven la práctica totalidad de los casos reales.

destination-out: el borrador. Lo que dibujes borra lo que había, con la forma que dibujes y respetando su antialiasing. Es la herramienta de goma de borrar de cualquier aplicación de dibujo, y también la forma de hacer agujeros de cualquier forma.

// Goma de borrar con borde suave
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, 24, 0, Math.PI * 2);
ctx.fill();
ctx.globalCompositeOperation = 'source-over';

destination-in: la máscara. Conserva del destino solo lo que coincide con el origen. Es la forma de aplicar una máscara arbitraria a una imagen ya dibujada.

// Recortar una foto con una forma cualquiera
ctx.drawImage(foto, 0, 0);
ctx.globalCompositeOperation = 'destination-in';
ctx.beginPath();
ctx.roundRect(20, 20, 200, 200, 40);
ctx.fill();
ctx.globalCompositeOperation = 'source-over';

Frente a clip, esta técnica tiene dos ventajas: la máscara puede tener transparencia parcial —un degradado como máscara produce un desvanecido— y se puede construir de forma acumulativa dibujando varias formas antes de aplicarla. Y una desventaja: afecta a todo el canvas, así que hay que hacerlo en una capa aparte.

source-atop: pintar dentro. Lo que dibujes solo aparece donde ya había algo. Es la forma de colorear, texturizar o iluminar una silueta existente sin salirse.

// Tenir una silueta ya dibujada
ctx.drawImage(sprite, 0, 0);
ctx.globalCompositeOperation = 'source-atop';
ctx.fillStyle = 'rgba(243,139,168,0.6)';
ctx.fillRect(0, 0, W, H);              // el rectangulo solo tine el sprite
ctx.globalCompositeOperation = 'source-over';

lighter: la luz que se suma

lighter no es de la familia de Porter-Duff estricta pero se agrupa con ellos, y su comportamiento es la suma de los dos colores, con saturación en 255.

Eso lo convierte en el operador de la luz: dos luces superpuestas dan más luz, nunca menos. Es exactamente cómo se comporta la luz física y es la razón de que sea el operador de partículas, destellos, estelas y efectos de brillo.

// Nube de particulas luminosas
ctx.globalCompositeOperation = 'lighter';
for (const p of particulas) {
  const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.r);
  g.addColorStop(0, `rgba(137,180,250,${p.vida})`);
  g.addColorStop(1, 'rgba(137,180,250,0)');
  ctx.fillStyle = g;
  ctx.fillRect(p.x - p.r, p.y - p.r, p.r * 2, p.r * 2);
}
ctx.globalCompositeOperation = 'source-over';

Ese ejemplo tiene un problema de rendimiento que ya conoces —crea un degradado por partícula y por fotograma— y la solución también: precalcular un degradado radial en un canvas pequeño y dibujarlo escalado con drawImage, que además es mucho más rápido.

// Textura de particula, creada UNA vez
const textura = document.createElement('canvas');
textura.width = textura.height = 64;
(() => {
  const c = textura.getContext('2d');
  const g = c.createRadialGradient(32, 32, 0, 32, 32, 32);
  g.addColorStop(0, 'rgba(255,255,255,1)');
  g.addColorStop(1, 'rgba(255,255,255,0)');
  c.fillStyle = g; c.fillRect(0, 0, 64, 64);
})();

// En el bucle: solo drawImage
ctx.globalCompositeOperation = 'lighter';
for (const p of particulas) {
  ctx.globalAlpha = p.vida;
  ctx.drawImage(textura, p.x - p.r, p.y - p.r, p.r * 2, p.r * 2);
}
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = 'source-over';
Los operadores destination-* son destructivos y solo tienen sentido en una capa aparte

Aquí está el error estructural que comete todo el que descubre estos operadores. Prueba destination-in en su canvas principal, funciona, se pone contento, y dos horas después tiene un canvas donde la mitad de los elementos desaparecen sin motivo aparente. La causa es que todos los operadores de la familia destination-* y también source-in, source-out y copy afectan al canvas entero, incluidas las zonas donde tu forma no llega. destination-in con un círculo pequeño no recorta la imagen a un círculo: borra todo el canvas salvo ese círculo, incluidos la interfaz, el fondo y los elementos que dibujaste hace veinte líneas. Y aquí está el matiz que resuelve el problema y que casi nadie menciona: la región de recorte sí limita el alcance de la composición. Un clip activo confina el efecto del operador a esa región, con lo que puedes usar destination-in sobre una zona sin destruir el resto. Eso da dos estrategias válidas y una regla. La primera estrategia es la capa aparte: compón el efecto en un canvas auxiliar del tamaño mínimo necesario y trae el resultado con drawImage y source-over. Es lo más limpio, cuesta un canvas y una copia, y es lo que hacen todas las bibliotecas serias. La segunda es el clip, que evita el canvas auxiliar a costa de que la composición interactúe con la pila de estado de formas que hay que tener muy claras. Y la regla, que vale para los dos casos: si el operador que vas a usar no empieza por source-over o lighter, pregúntate primero cuál es exactamente el rectángulo que quieres afectar, y garantízalo con un recorte o con una capa antes de tocar nada.

Una utilidad de capa

/**
 * Compone un efecto en una capa aparte y lo trae al canvas principal.
 * dibujarCapa recibe un contexto limpio del tamano indicado.
 */
function enCapa(ctx, x, y, ancho, alto, dibujarCapa, dpr = 1) {
  const capa = document.createElement('canvas');
  capa.width = Math.ceil(ancho * dpr);
  capa.height = Math.ceil(alto * dpr);
  const c = capa.getContext('2d');
  c.setTransform(dpr, 0, 0, dpr, 0, 0);
  dibujarCapa(c, ancho, alto);
  ctx.drawImage(capa, x, y, ancho, alto);
  capa.width = capa.height = 0;        // liberar el buffer
}

// Uso: una foto con mascara de degradado, sin tocar el resto del canvas
enCapa(ctx, 40, 40, 240, 180, (c, w, h) => {
  c.drawImage(foto, 0, 0, w, h);
  c.globalCompositeOperation = 'destination-in';
  const g = c.createLinearGradient(0, 0, 0, h);
  g.addColorStop(0, 'rgba(0,0,0,1)');
  g.addColorStop(1, 'rgba(0,0,0,0)');
  c.fillStyle = g;
  c.fillRect(0, 0, w, h);
}, window.devicePixelRatio || 1);

Crear un canvas por efecto y por fotograma es caro; en un bucle de animación hay que reutilizar la misma capa, limpiándola con clearRect en lugar de crearla de nuevo. La versión de arriba es correcta para efectos puntuales y hay que adaptarla para el bucle.