Saltar al contenido

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.

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.

HTML
<!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.

JS
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:

JS
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:

JS
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.

Un efecto compuesto

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

HTML
<!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.