wandres.dev
ANIMACIÓN EN CANVAS · El bucle y el estado

Borrado completo frente a borrado parcial: las estelas

Elegir cómo limpiar el fotograma anterior, hacer estelas con un rectángulo translúcido, y entender por qué nunca desaparecen del todo y cómo evitarlo.

⏱ 17 min

Cada fotograma empieza con una decisión que casi nadie toma conscientemente: qué hacer con lo que había antes. Borrarlo entero es lo correcto por defecto y lo que espera cualquiera que lea el código. No borrarlo del todo, sino atenuarlo, produce estelas con dos líneas y sin guardar ninguna historia, y es uno de los efectos más agradecidos del canvas. También es uno de los que peor envejecen, porque tiene un defecto que solo se aprecia después de varios minutos de ejecución.

🎯 Al terminar esta lección sabrás
  • Borrar el fotograma con el método adecuado según el contexto tenga alfa o no.
  • Producir estelas atenuando el fotograma anterior en lugar de guardar historia.
  • Explicar por qué una estela por alfa deja residuo permanente.
  • Elegir entre las cuatro técnicas de estela según lo que necesite la escena.

Borrar entero

Hay tres formas de dejar el canvas limpio y no son equivalentes.

ctx.clearRect(0, 0, ancho, alto);        // deja negro transparente
ctx.fillStyle = '#1e1e2e';
ctx.fillRect(0, 0, ancho, alto);         // deja un color opaco
canvas.width = canvas.width;             // reinicia el bufer y TODO el estado

clearRect es la respuesta por defecto. Deja los píxeles en negro transparente, con lo que se ve lo que haya detrás del canvas en la página, y respeta la matriz vigente: si tienes puesta la transformación de densidad, las coordenadas que le pasas son lógicas.

fillRect con un color opaco es lo correcto cuando el canvas tiene fondo propio, y tiene una ventaja de rendimiento real: pintar opaco permite al motor descartar todo lo que hubiera debajo, mientras que borrar a transparente obliga a mantener la información de alfa. En un canvas creado con alpha: false es además lo único que tiene sentido, porque ahí no existe la transparencia.

Reasignar canvas.width libera y recrea el búfer. Borra también toda la pila de estado, la matriz, los estilos y el recorte. Es la opción correcta para liberar memoria de un canvas que ya no se usa, y una elección pésima como borrado por fotograma: asignar el ancho fuerza una reasignación completa del búfer en cada llamada.

Un detalle que sorprende: clearRect respeta la región de recorte pero ignora el operador de composición. Siempre borra. Si has dejado puesto un globalCompositeOperation exótico y esperas que el borrado se comporte de otra manera, no lo hará; el operador afecta a lo que pintas, no a lo que borras.

La estela por atenuación

En vez de borrar, se pinta encima un rectángulo casi transparente del color del fondo. Lo que había queda un poco más tenue en cada fotograma, y el resultado es una estela que se desvanece sin que hayas guardado ni una posición anterior.

<canvas id="c" style="width:640px;height:400px;display:block;background:#1e1e2e"></canvas>
<script type="module">
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d', { alpha: false });
const dpr = window.devicePixelRatio || 1;
let ancho = 640, alto = 400;

function dimensionar() {
  const caja = canvas.getBoundingClientRect();
  ancho = caja.width; alto = caja.height;
  canvas.width = Math.round(ancho * dpr);
  canvas.height = Math.round(alto * dpr);
  ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  ctx.fillStyle = '#1e1e2e';
  ctx.fillRect(0, 0, ancho, alto);
}

const puntos = Array.from({ length: 60 }, (_, i) => ({
  a: (i / 60) * Math.PI * 2,
  v: 0.4 + (i % 7) * 0.06,
  r: 60 + (i % 5) * 28,
}));

let anterior = null;
function marco(ahora) {
  if (anterior === null) anterior = ahora;
  const dt = Math.min(50, ahora - anterior) / 1000;
  anterior = ahora;

  // Borrado parcial: la estela
  ctx.fillStyle = 'rgba(30, 30, 46, 0.10)';
  ctx.fillRect(0, 0, ancho, alto);

  ctx.fillStyle = '#89b4fa';
  for (const p of puntos) {
    p.a += p.v * dt;
    const x = ancho / 2 + Math.cos(p.a) * p.r;
    const y = alto / 2 + Math.sin(p.a * 1.3) * p.r * 0.6;
    ctx.beginPath();
    ctx.arc(x, y, 3, 0, Math.PI * 2);
    ctx.fill();
  }
  requestAnimationFrame(marco);
}

new ResizeObserver(dimensionar).observe(canvas);
dimensionar();
requestAnimationFrame(marco);
</script>

El único parámetro es el alfa del rectángulo, y controla la longitud de la estela de forma inversa: 0.02 deja rastros larguísimos, 0.30 apenas un desenfoque de movimiento. Y como es un alfa por fotograma, la longitud de la estela depende de la frecuencia de la pantalla: la misma cifra da estelas más cortas en un monitor de 120 hercios. Corregirlo es la misma potencia de la lección anterior:

const RESTANTE_POR_SEGUNDO = 0.002;      // cuanto queda de un rastro tras un segundo
const alfa = 1 - Math.pow(RESTANTE_POR_SEGUNDO, dt);
ctx.fillStyle = `rgba(30, 30, 46, ${alfa})`;
La estela por alfa nunca llega a cero, y el residuo se queda en pantalla para siempre

Prueba el ejemplo de arriba en un canvas con transparencia en lugar de sin ella, déjalo corriendo cinco minutos y mira la zona por donde pasaron los puntos al principio. Sigue ahí: una mancha tenue que ya no se atenúa más. No es un defecto de tu código, es aritmética de enteros de ocho bits. Cada fotograma multiplica el valor del canal por un factor menor que uno y redondea al entero más cercano, y en cuanto el valor es pequeño, la multiplicación cae dentro del mismo entero: multiplicar 3 por 0.9 da 2.7, que redondea a 3. Ahí se detiene la sucesión y el píxel se queda con ese valor para siempre. Con un alfa de borrado del diez por ciento, el punto fijo aparece en valores de canal de uno a cinco, invisible sobre un fondo claro y perfectamente visible como una neblina sobre un fondo oscuro. Hay cuatro salidas y conviene conocer las cuatro porque sirven en casos distintos. La primera, y la mejor si te vale: usar un contexto opaco, creado con alpha: false, y atenuar hacia el color de fondo con un rectángulo opaco de ese mismo color. El punto fijo sigue existiendo, pero ahora converge al color del fondo en lugar de a un residuo sobre transparencia, así que es literalmente invisible. Es la razón por la que las estelas se ven limpias en un canvas con fondo y sucias en uno transparente. La segunda: borrar de verdad cada cierto tiempo. Un clearRect completo cada pocos segundos elimina el residuo; se nota como un parpadeo si la estela es larga, y no se nota nada si es corta. La tercera: atenuar restando alfa en lugar de pintando encima, con globalCompositeOperation = 'destination-out' y un relleno translúcido. Reduce el canal alfa en lugar de mezclar color, lo que evita el tinte, pero sufre exactamente el mismo redondeo: el alfa se queda clavado en valores de uno o dos. Mejora el aspecto, no resuelve el problema. La cuarta, la única que es exacta: no atenuar píxeles, sino guardar la historia. Se conservan las últimas posiciones y se dibujan como una polilínea con opacidad decreciente. Es más código, cuesta más CPU, y a cambio la estela desaparece exactamente cuando debe, se puede redibujar en cualquier momento —importantísimo si redimensionas—, y no impide el redibujado por regiones. Cualquier estela que forme parte del contenido, y no solo del adorno, debería estar hecha así.

Las cuatro técnicas, comparadas

Técnica Coste Se puede redibujar Residuo
Rectángulo translúcido sobre canvas opaco Un relleno de pantalla completa No Invisible
Rectángulo translúcido sobre canvas con alfa Un relleno de pantalla completa No Visible con el tiempo
destination-out translúcido Un relleno de pantalla completa No Leve pero presente
Historia de posiciones dibujada Proporcional al largo de la estela Ninguno

La estela geométrica merece verse escrita, porque es más corta de lo que la gente imagina:

const HISTORIA = 40;

function actualizar(m, dt) {
  for (const p of m.puntos) {
    p.a += p.v * dt;
    p.rastro.push({ x: p.x, y: p.y });
    if (p.rastro.length > HISTORIA) p.rastro.shift();
  }
}

function dibujarRastro(ctx, p) {
  const n = p.rastro.length;
  ctx.lineCap = 'round';
  for (let i = 1; i < n; i++) {
    const t = i / n;                            // 0 el mas viejo, 1 el mas nuevo
    ctx.globalAlpha = t * t;                    // cuadratico: se apaga antes
    ctx.lineWidth = 1 + t * 3;
    ctx.strokeStyle = p.color;
    ctx.beginPath();
    ctx.moveTo(p.rastro[i - 1].x, p.rastro[i - 1].y);
    ctx.lineTo(p.rastro[i].x, p.rastro[i].y);
    ctx.stroke();
  }
  ctx.globalAlpha = 1;
}

Un shift por fotograma sobre un array es más caro de lo que parece cuando hay miles de partículas; la versión de producción usa un búfer circular con un índice, que evita el desplazamiento de memoria.

Lo que la estela te quita

Merece la pena decirlo explícitamente porque condiciona todo el nivel siguiente: una estela por atenuación obliga a tocar todos los píxeles del canvas en cada fotograma. Eso elimina de raíz la posibilidad de redibujar solo la región que ha cambiado, que es la optimización de mayor retorno del canvas y el asunto de la región sucia.

En un canvas pequeño no importa. En uno de pantalla completa a densidad dos, un relleno translúcido de pantalla completa son varios millones de píxeles mezclados por fotograma, y ese solo relleno puede consumir la mitad del presupuesto. La forma de comprobarlo es directa: quita la estela, mide, y decide si el efecto vale su precio.