wandres.dev
COMPOSICIÓN · globalCompositeOperation

El modelo de composición: origen, destino y globalAlpha

Entender qué son el origen y el destino en cada operación de dibujo, cómo se combinan por defecto, y qué papel juega globalAlpha en la ecuación.

⏱ 16 min

Cada vez que dibujas algo, el canvas tiene que decidir qué hacer con lo que ya había. Esa decisión la gobierna un operador de composición, y aunque el valor por defecto —pintar encima respetando la transparencia— es tan natural que ni se nota, cambiarlo desbloquea máscaras, recortes, iluminación y una familia entera de efectos que de otra manera exigirían leer píxeles a mano.

🎯 Al terminar esta lección sabrás
  • Definir origen y destino en el modelo de composición del canvas.
  • Escribir la ecuación de source-over y explicar el papel del alfa premultiplicado.
  • Usar globalAlpha correctamente y distinguirlo de un color semitransparente.
  • Reconocer que la composición afecta a todo el canvas, no solo a la forma dibujada.

Origen y destino

En toda operación de composición hay dos imágenes.

El destino es lo que ya hay en el canvas antes de la operación.

El origen es lo que estás dibujando ahora: la forma rellena, el texto, la imagen.

El operador decide cómo se combinan. La propiedad que lo controla es:

ctx.globalCompositeOperation = 'source-over';   // valor por defecto

Con source-over, el origen se pinta encima del destino respetando su transparencia. Es lo que espera todo el mundo y por eso pasa desapercibido.

La ecuación, con los colores en alfa premultiplicado, es una interpolación:

resultado = origen + destino × (1 − alfaOrigen)

Es decir: el color del origen se suma tal cual, y el del destino se atenúa en proporción a lo que el origen tape. Con alfa de origen igual a 1 el destino desaparece por completo; con alfa 0 el destino queda intacto.

Esa fórmula es la que hace que el alfa premultiplicado sea tan cómodo: no hay ninguna multiplicación repetida, solo una suma y un producto. Es la razón de que prácticamente todos los motores de composición trabajen en ese espacio.

globalAlpha

ctx.globalAlpha = 0.5;    // de 0 a 1

globalAlpha multiplica el alfa de todo lo que dibujes después de asignarlo. Se aplica antes de la composición, así que afecta al origen y no al destino.

Hay una diferencia importante entre globalAlpha y usar un color semitransparente, y es sutil pero real:

// A: color con alfa
ctx.fillStyle = 'rgba(137,180,250,0.5)';
ctx.fillRect(20, 20, 100, 100);
ctx.fillRect(70, 70, 100, 100);       // la zona solapada se ve mas opaca

// B: globalAlpha
ctx.globalAlpha = 0.5;
ctx.fillStyle = '#89b4fa';
ctx.fillRect(220, 20, 100, 100);
ctx.fillRect(270, 70, 100, 100);      // igual: la zona solapada tambien

En este caso concreto son equivalentes, porque cada fillRect es una operación de composición independiente. Pero cuando el origen es una forma compleja con partes solapadas —una ruta con varias subrutas que se cruzan, o un drawImage de algo con transparencia interna— la diferencia aparece: globalAlpha se aplica al resultado del origen ya compuesto internamente, mientras que un color con alfa se aplica a cada primitiva.

La regla que se deriva: para atenuar un grupo entero como una unidad, globalAlpha no basta. Hay que componer el grupo en un canvas auxiliar y dibujarlo después con globalAlpha, que es exactamente lo que hace la propiedad opacity de CSS con un grupo de elementos.

// Atenuar un grupo como una unidad
const capa = document.createElement('canvas');
capa.width = W; capa.height = H;
const cctx = capa.getContext('2d');
dibujarGrupo(cctx);                     // las partes se solapan a plena opacidad
ctx.globalAlpha = 0.5;
ctx.drawImage(capa, 0, 0);              // el grupo entero al 50 %
ctx.globalAlpha = 1;

Un detalle de la especificación: asignar a globalAlpha un valor fuera del rango de 0 a 1, o no finito, se ignora en silencio y la propiedad conserva el valor anterior. Es la misma trampa que en el resto de la API, y produce el bug de un elemento que aparece con la opacidad de otro.

La composición afecta a todo el canvas

Esta es la parte que sorprende y que hay que interiorizar antes de tocar los operadores exóticos.

Un operador de composición no se aplica solo al rectángulo que ocupa la forma que dibujas: se aplica a todo el canvas. La forma define el origen, y el origen en las zonas donde no hay forma es transparente, pero eso no significa “no hacer nada”: significa “componer con un origen transparente”.

Con source-over, componer con origen transparente deja el destino intacto, así que el efecto es indistinguible de no hacer nada. Con otros operadores, no.

ctx.fillStyle = '#a6e3a1';
ctx.fillRect(0, 0, 300, 200);          // fondo verde

ctx.globalCompositeOperation = 'source-in';
ctx.fillStyle = '#f38ba8';
ctx.fillRect(50, 50, 100, 100);
// Resultado: SOLO queda el rectangulo rosa. Todo el verde ha desaparecido,
// porque source-in conserva el destino solo donde hay origen.

Es el comportamiento correcto según la definición del operador y es la causa número uno de “mi canvas se ha borrado”. La defensa es limitar el alcance de la operación con un recorte, o —mucho más habitual— hacer la composición en un canvas auxiliar y traer el resultado.

Restaurar el operador

globalCompositeOperation es estado y persiste. Olvidar restaurarlo produce efectos desconcertantes muy lejos de la causa.

function conComposicion(ctx, modo, dibujar) {
  const anterior = ctx.globalCompositeOperation;
  ctx.globalCompositeOperation = modo;
  try { dibujar(); } finally { ctx.globalCompositeOperation = anterior; }
}

O, si ya estás usando la pila para otra cosa, save y restore lo guardan igual que al resto del estado.

El operador copy y el canvas que se limpia solo

Entre los operadores hay uno que merece mención aparte porque resuelve un problema concreto muy bien y provoca otro muy mal: copy. Con copy, el resultado es solo el origen: el destino se descarta por completo, incluidas las zonas donde el origen es transparente. Es decir, cada operación de dibujo con copy borra el canvas y deja únicamente lo que acabas de dibujar. Eso tiene un uso legítimo y elegante: sustituir el par clearRect más drawImage por un solo drawImage, que en un bucle de vídeo o de composición de capas ahorra una operación sobre todo el búfer en cada fotograma. En canvas grandes es una mejora medible. El problema es que copy es también el operador que más veces produce el fallo de “mi canvas parpadea” o “solo se ve el último elemento”, porque alguien lo dejó activo y todos los dibujos siguientes se borran unos a otros. Y hay una interacción con el recorte que casi nadie conoce y que es la que de verdad muerde: con una región de recorte activa, copy solo borra dentro del recorte, así que el comportamiento cambia por completo según haya clip o no, y el mismo código produce dos resultados distintos según en qué punto de tu jerarquía de capas se ejecute. Si usas copy, úsalo en un canvas dedicado, en una sola línea, y restaura el operador inmediatamente. Y si ves un canvas que muestra solo el último elemento dibujado, busca un globalCompositeOperation sin restaurar antes de sospechar de tu bucle.

Un ejemplo del modelo completo

<!doctype html>
<meta charset="utf-8">
<canvas id="c" width="560" height="200" style="background:#181825"></canvas>
<script>
  const ctx = document.getElementById('c').getContext('2d');

  function pareja(x, modo, etiqueta) {
    ctx.save();
    ctx.translate(x, 20);

    // El "destino": un cuadrado azul
    ctx.globalCompositeOperation = 'source-over';
    ctx.fillStyle = '#89b4fa';
    ctx.fillRect(0, 0, 80, 80);

    // El "origen": un circulo rosa, compuesto con el modo indicado
    ctx.globalCompositeOperation = modo;
    ctx.fillStyle = '#f38ba8';
    ctx.beginPath();
    ctx.arc(80, 80, 42, 0, Math.PI * 2);
    ctx.fill();

    ctx.globalCompositeOperation = 'source-over';
    ctx.fillStyle = '#a6adc8';
    ctx.font = '12px system-ui';
    ctx.fillText(etiqueta, 0, 140);
    ctx.restore();
  }

  // Cada pareja en su propio canvas conceptual gracias al clip
  ['source-over', 'source-atop', 'destination-over', 'xor'].forEach((m, i) => {
    ctx.save();
    ctx.beginPath();
    ctx.rect(20 + i * 135, 10, 125, 180);
    ctx.clip();                       // limita el alcance de la composicion
    pareja(20 + i * 135, m, m);
    ctx.restore();
  });
</script>

El clip alrededor de cada pareja es lo que permite mostrar cuatro operadores en el mismo canvas: sin él, cada operador afectaría a todo el lienzo y se destruirían entre sí. Es la primera aplicación práctica de la interacción entre recorte y composición, que tiene su propia lección.