wandres.dev
COMPOSICIÓN · globalCompositeOperation

Los efectos que la composición desbloquea

Construir con operadores de composición los efectos que de otra forma exigirían manipular píxeles: máscaras, iluminación, rasca y gana, texto calado y estelas.

⏱ 18 min

La composición es la herramienta menos usada del canvas y la que más problemas resuelve sin escribir un bucle de píxeles. Máscaras suaves, focos de luz sobre una escena oscura, texto que revela una imagen a través de sus letras, capas que se borran al pasar el dedo: todos son dos o tres llamadas con el operador adecuado, y todos son cientos de líneas y varios órdenes de magnitud más lentos si se hacen a mano.

🎯 Al terminar esta lección sabrás
  • Implementar una máscara con degradado y un efecto de rasca y gana.
  • Construir un foco de luz sobre una escena oscurecida.
  • Calar texto o una forma sobre un fondo con destination-out.
  • Combinar varias capas con operadores distintos en un efecto compuesto.

Rasca y gana

El efecto de raspar para revelar lo que hay debajo es destination-out en su forma más pura: la capa de cobertura se borra donde el usuario pasa el dedo, dejando ver lo que hay detrás en la página.

<!doctype html>
<meta charset="utf-8">
<style>
  .rasca { position: relative; width: 320px; height: 180px; }
  .premio { position: absolute; inset: 0; display: grid; place-items: center;
            background: #1e1e2e; color: #a6e3a1; font: 700 34px system-ui; }
  canvas { position: absolute; inset: 0; touch-action: none; cursor: crosshair; }
</style>
<div class="rasca">
  <div class="premio">¡Has ganado!</div>
  <canvas id="c" width="320" height="180"></canvas>
</div>
<script>
  const canvas = document.getElementById('c');
  const ctx = canvas.getContext('2d');

  // Capa de cobertura
  const g = ctx.createLinearGradient(0, 0, 320, 180);
  g.addColorStop(0, '#585b70'); g.addColorStop(1, '#9399b2');
  ctx.fillStyle = g;
  ctx.fillRect(0, 0, 320, 180);
  ctx.fillStyle = '#11111b';
  ctx.font = '600 18px system-ui';
  ctx.textAlign = 'center';
  ctx.fillText('Rasca aquí', 160, 96);

  let rascando = false;
  ctx.lineCap = ctx.lineJoin = 'round';
  ctx.lineWidth = 34;

  function punto(e) {
    const r = canvas.getBoundingClientRect();
    return { x: (e.clientX - r.left) * (canvas.width / r.width),
             y: (e.clientY - r.top) * (canvas.height / r.height) };
  }

  canvas.addEventListener('pointerdown', e => {
    canvas.setPointerCapture(e.pointerId);
    rascando = true;
    const p = punto(e);
    ctx.globalCompositeOperation = 'destination-out';
    ctx.beginPath();
    ctx.moveTo(p.x, p.y);
    ctx.lineTo(p.x, p.y);
    ctx.stroke();
  });
  canvas.addEventListener('pointermove', e => {
    if (!rascando) return;
    for (const m of e.getCoalescedEvents?.() ?? [e]) {
      const p = punto(m);
      ctx.lineTo(p.x, p.y);
    }
    ctx.stroke();
  });
  canvas.addEventListener('pointerup', () => { rascando = false; });
</script>

Dos detalles. El getCoalescedEvents recupera las muestras intermedias que el navegador agrupó, lo que produce trazos suaves con dispositivos de alta frecuencia de muestreo. Y el lineCap y lineJoin redondeados son lo que hace que el raspado se sienta como un dedo y no como una cuchilla.

Foco de luz

Oscurecer una escena y abrir un hueco de luz es una forma con agujero, y hay dos maneras de hacerlo: con el sentido del recorrido y la regla de relleno, o con destination-out. La segunda permite bordes suaves.

function foco(ctx, ancho, alto, x, y, radio, oscuridad = 0.82) {
  // Capa de oscuridad
  ctx.save();
  ctx.fillStyle = `rgba(17,17,27,${oscuridad})`;
  ctx.fillRect(0, 0, ancho, alto);

  // Abrir el hueco con un degradado radial: borde suave
  ctx.globalCompositeOperation = 'destination-out';
  const g = ctx.createRadialGradient(x, y, radio * 0.35, x, y, radio);
  g.addColorStop(0, 'rgba(0,0,0,1)');
  g.addColorStop(1, 'rgba(0,0,0,0)');
  ctx.fillStyle = g;
  ctx.fillRect(x - radio, y - radio, radio * 2, radio * 2);
  ctx.restore();
}

Ese destination-out con un degradado en lugar de un color plano es lo que produce el desvanecido. Con un arc relleno de negro, el borde sería duro.

El mismo patrón sirve para el resaltado de un tour guiado, para el efecto de linterna de un juego y para dirigir la atención en una presentación.

Texto calado sobre una imagen

Que una imagen se vea a través de las letras es destination-in con el texto como origen, en una capa aparte:

function textoCalado(ctx, imagen, texto, x, y, ancho, alto) {
  const capa = document.createElement('canvas');
  capa.width = ancho; capa.height = alto;
  const c = capa.getContext('2d');

  // 1. La imagen que se vera dentro de las letras
  c.drawImage(imagen, 0, 0, ancho, alto);

  // 2. Conservar solo donde hay texto
  c.globalCompositeOperation = 'destination-in';
  c.font = '900 96px system-ui';
  c.textAlign = 'center';
  c.textBaseline = 'middle';
  c.fillStyle = '#000';                  // el color da igual: solo cuenta el alfa
  c.fillText(texto, ancho / 2, alto / 2);

  ctx.drawImage(capa, x, y);
  capa.width = capa.height = 0;
}

La variante inversa —el texto es un agujero en un rectángulo de color— se consigue con destination-out, y sirve para el efecto de “ventana” en un panel.

Iluminación aditiva

Un efecto de luces sobre una escena oscura combina lighter con degradados radiales precalculados. La clave del rendimiento es no crear los degradados en el bucle:

function crearLuz(radio, color) {
  const c = document.createElement('canvas');
  c.width = c.height = radio * 2;
  const x = c.getContext('2d');
  const g = x.createRadialGradient(radio, radio, 0, radio, radio, radio);
  g.addColorStop(0, color);
  g.addColorStop(0.4, color.replace(/[\d.]+\)$/, '0.25)'));
  g.addColorStop(1, color.replace(/[\d.]+\)$/, '0)'));
  x.fillStyle = g;
  x.fillRect(0, 0, radio * 2, radio * 2);
  return c;
}

const luzCalida = crearLuz(128, 'rgba(249,226,175,1)');
const luzFria = crearLuz(128, 'rgba(137,180,250,1)');

function pintarLuces(ctx, luces) {
  ctx.save();
  ctx.globalCompositeOperation = 'lighter';
  for (const l of luces) {
    const t = l.intensidad;
    ctx.globalAlpha = t;
    const d = l.radio * 2;
    ctx.drawImage(l.textura, l.x - l.radio, l.y - l.radio, d, d);
  }
  ctx.restore();
}

Con las texturas precalculadas, dibujar doscientas luces son doscientos drawImage, que es una carga perfectamente asumible. Con degradados creados en el bucle sería inviable.

Las estelas con alfa nunca llegan a cero, y hay dos formas de arreglarlo

El efecto de estela más difundido consiste en no borrar el canvas sino cubrirlo con un rectángulo semitransparente del color de fondo en cada fotograma, de modo que lo antiguo se va desvaneciendo. Funciona, es precioso, y tiene un fallo que aparece a los pocos segundos: la estela nunca desaparece del todo. Queda un residuo fantasma permanente que ensucia el canvas. La causa es aritmética y es inevitable con ocho bits por canal: cada fotograma multiplica el alfa restante por un factor menor que uno, pero el resultado se redondea a un entero, y por debajo de cierto valor el redondeo devuelve el mismo número. Con un alfa de borrado de 0,05, un píxel con valor 3 se queda en 3 para siempre. Hay dos soluciones y las dos son buenas. La primera es usar destination-out en lugar de un rectángulo de color. Con destination-out no estás mezclando color sino restando alfa, y el alfa sí llega a cero porque la operación es una multiplicación del canal alfa que acaba cruzando el umbral de redondeo hacia abajo. Además tiene la ventaja de que el canvas queda transparente y se ve el fondo de la página, en lugar de acumular una capa de color. La segunda es forzar un borrado completo periódico: un clearRect cada cierto número de fotogramas o cuando la escena esté quieta. Es tosco y funciona. Y hay un tercer camino, el correcto cuando el efecto importa de verdad: mantener la estela como dato, guardando las últimas N posiciones de cada elemento y redibujándolas con opacidad decreciente. Cuesta memoria y CPU, y a cambio la estela tiene exactamente la longitud que decidas, no deja residuo, y funciona igual a cualquier tasa de fotogramas, que es algo que las dos primeras técnicas no consiguen: con el borrado por alfa, una estela se ve más larga en un monitor de 60 hercios que en uno de 144.

// Estela sin residuo: restar alfa en lugar de mezclar color
ctx.globalCompositeOperation = 'destination-out';
ctx.fillStyle = 'rgba(0,0,0,0.08)';
ctx.fillRect(0, 0, ancho, alto);
ctx.globalCompositeOperation = 'source-over';

Un efecto compuesto

Reuniendo varias piezas, este es un ejemplo completo y funcional de escena oscura con luces móviles y estela sin residuo:

<!doctype html>
<meta charset="utf-8">
<style> body { margin:0; background:#11111b; } canvas { display:block; } </style>
<canvas id="c" width="640" height="400"></canvas>
<script>
  const ctx = document.getElementById('c').getContext('2d');

  const textura = document.createElement('canvas');
  textura.width = textura.height = 256;
  (() => {
    const x = textura.getContext('2d');
    const g = x.createRadialGradient(128, 128, 0, 128, 128, 128);
    g.addColorStop(0, 'rgba(255,255,255,0.9)');
    g.addColorStop(0.3, 'rgba(255,255,255,0.25)');
    g.addColorStop(1, 'rgba(255,255,255,0)');
    x.fillStyle = g; x.fillRect(0, 0, 256, 256);
  })();

  const luces = Array.from({ length: 5 }, (_, i) => ({
    a: Math.random() * 7, v: 0.004 + Math.random() * 0.006,
    rx: 120 + Math.random() * 140, ry: 60 + Math.random() * 90,
    r: 60 + Math.random() * 70,
    color: ['#f38ba8', '#a6e3a1', '#89b4fa', '#f9e2af', '#cba6f7'][i],
  }));

  function marco() {
    // Estela: restar alfa, no mezclar color
    ctx.globalCompositeOperation = 'destination-out';
    ctx.fillStyle = 'rgba(0,0,0,0.06)';
    ctx.fillRect(0, 0, 640, 400);

    ctx.globalCompositeOperation = 'lighter';
    for (const l of luces) {
      l.a += l.v;
      const x = 320 + Math.cos(l.a) * l.rx;
      const y = 200 + Math.sin(l.a * 1.3) * l.ry;
      ctx.save();
      ctx.globalCompositeOperation = 'lighter';
      ctx.fillStyle = l.color;
      ctx.globalAlpha = 0.9;
      // Tenir la textura blanca con el color de la luz usando source-atop
      ctx.drawImage(textura, x - l.r, y - l.r, l.r * 2, l.r * 2);
      ctx.restore();
    }
    ctx.globalCompositeOperation = 'source-over';
    requestAnimationFrame(marco);
  }
  marco();
</script>

Cinco luces con estela, sin un solo acceso a píxeles y sin ningún objeto creado dentro del bucle. Todo lo hace el compositor.