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

El bucle con pausa y reanudación

Escribir el bucle definitivo: arranque idempotente, pausa al ocultarse la pestaña, parada cuando el canvas sale de la vista y dibujo bajo demanda cuando nada cambia.

⏱ 18 min

Un bucle que solo sabe correr es media implementación. La otra mitad es saber parar: al ocultarse la pestaña, al salir el canvas de la pantalla, al desmontarse el componente y, sobre todo, cuando no ha cambiado nada y redibujar sería tirar batería a cambio de una imagen idéntica. Esa segunda mitad es la que separa una animación de demostración de una que puede convivir con el resto de una aplicación real.

🎯 Al terminar esta lección sabrás
  • Implementar arranque y parada idempotentes que no dupliquen bucles.
  • Reaccionar a visibilitychange sin heredar un delta de varios segundos.
  • Detener el trabajo cuando el canvas no está en pantalla.
  • Dibujar solo cuando algo ha cambiado, sin perder la capacidad de animar.

Arrancar y parar sin duplicar

El fallo número uno de esta parte no es olvidar parar: es arrancar dos veces. Ocurre en cuanto hay un componente que se monta y desmonta, o un evento que llama al arranque por si acaso.

// Mal: dos llamadas dan dos bucles, y todo va al doble de velocidad
function arrancar() {
  requestAnimationFrame(marco);
}

Dos bucles simultáneos no duplican los fotogramas —la pantalla sigue refrescando igual— pero sí duplican las llamadas a actualizar por fotograma, con lo que el mundo avanza al doble. El síntoma es característico: la animación va bien, se vuelve a un componente y ahora va más rápido, y a la tercera vuelta va al triple.

La defensa es guardar el identificador y comprobarlo:

let id = 0;

function arrancar() {
  if (id) return;                     // ya esta corriendo
  anterior = null;
  id = requestAnimationFrame(marco);
}

function parar() {
  if (!id) return;
  cancelAnimationFrame(id);
  id = 0;
}

function marco(ahora) {
  if (anterior === null) anterior = ahora;
  const dt = Math.min(50, ahora - anterior);
  anterior = ahora;
  actualizar(mundo, dt);
  dibujar(ctx, mundo);
  id = requestAnimationFrame(marco);   // reasignar SIEMPRE
}

Reasignar id dentro del propio marco es lo que hace que parar funcione: sin eso, cancelas un identificador viejo y el bucle sigue vivo.

Y anterior = null en el arranque es lo que evita el delta gigante de la reanudación. Aunque el acotado ya protege la física, dejarlo limpio hace que el primer fotograma tras reanudar sea exacto en lugar de aproximado.

visibilitychange

Cuando el usuario cambia de pestaña, el navegador deja de llamar a requestAnimationFrame por su cuenta. Eso ya evita el consumo de CPU del dibujo, así que la pausa explícita no es para ahorrar fotogramas: es para todo lo demás.

document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    parar();
    pausarSonido();
    detenerSondeoDeRed();
  } else {
    arrancar();
  }
});

Lo que sí hace falta manejar a mano es lo que ocurre al volver. Sin pausa explícita, el primer fotograma tras regresar trae un delta igual a todo el tiempo que la pestaña estuvo oculta. El acotado lo salva, pero hay dos efectos que el acotado no cubre: los temporizadores propios que sí siguieron corriendo y quedan desincronizados con el mundo, y cualquier animación basada en un tiempo absoluto, que dará un salto grande.

Hay tres eventos más de la misma familia que conviene conocer porque visibilitychange no cubre todos los casos:

Evento Cuándo llega Qué hacer
visibilitychange Cambio de pestaña, minimizar, bloquear la pantalla Parar y reanudar
pagehide Se abandona la página, incluso hacia la caché de retroceso Liberar recursos, guardar estado
pageshow Se vuelve a la página, incluso desde la caché de retroceso Rearrancar el bucle si venía de la caché
blur en la ventana La ventana pierde el foco sin ocultarse Normalmente nada: la pestaña sigue visible

La caché de retroceso merece atención: al volver con el botón de atrás, la página se restaura tal cual estaba, con el bucle parado y sin que se dispare ningún load. Si solo escuchas visibilitychange, hay navegadores en los que la animación no rearranca. Escuchar pageshow y comprobar evento.persisted lo cubre.

window.addEventListener('pageshow', e => { if (e.persisted) arrancar(); });
window.addEventListener('pagehide', () => parar());
Que el navegador pause tu bucle no significa que tu aplicación esté parada, y las tres excepciones importan

La creencia cómoda es que el navegador ya se encarga: si la pestaña está oculta no hay fotogramas, así que no hace falta hacer nada. Es cierto para el dibujo y falso para todo lo demás, y las tres excepciones producen fallos que se manifiestan como consumo de batería o como estados incoherentes al volver. Primera: el canvas que está fuera de la pantalla sigue recibiendo fotogramas. El navegador pausa por pestaña, no por visibilidad del elemento. Un canvas animado en el pie de una página larga consume el mismo presupuesto estando el usuario en la cabecera, y en una página con varios de ellos el resultado es un móvil caliente y una animación a tirones sin que nada de eso aparezca en el sitio donde estás mirando. La solución es un IntersectionObserver sobre el canvas, y es tan barata que debería estar en la plantilla de cualquier animación. Segunda: una ventana tapada por otra puede seguir siendo visible para el navegador. El estado oculto responde a cambios de pestaña y a minimizar, no a que otra aplicación esté delante. En un escritorio con ventanas solapadas puedes estar animando indefinidamente algo que nadie ve. No hay API para detectarlo con fiabilidad, y por eso importa tanto la tercera. Tercera, y es la que de verdad cambia el consumo: si tu escena no está animada, no deberías tener bucle en absoluto. La mayoría de los canvas de una aplicación no son animaciones: son gráficos, diagramas, editores. Redibujarlos sesenta veces por segundo para mostrar exactamente la misma imagen es el desperdicio más extendido del canvas, y no aparece en ningún perfil como un problema porque cada fotograma es rapidísimo; simplemente hay millones de ellos. El patrón correcto es dibujo bajo demanda: nada se dibuja hasta que alguien llama a invalidar, y entonces se programa exactamente un fotograma. Un editor de diagramas bien escrito dibuja cero fotogramas mientras el usuario piensa, uno por cada movimiento del ratón, y una ráfaga continua solo mientras hay una transición viva. La diferencia medida en un portátil es de varios puntos porcentuales de un núcleo mantenidos durante horas, que en una aplicación que la gente deja abierta todo el día es la diferencia entre una queja de batería y ninguna.

El bucle definitivo

Todo lo anterior en una clase reutilizable que cubre los tres modos: parado, animando de forma continua, y dibujando bajo demanda.

export class Bucle {
  constructor({ elemento, actualizar, dibujar, dtMaximo = 50 }) {
    this.elemento = elemento;
    this.actualizar = actualizar;
    this.dibujar = dibujar;
    this.dtMaximo = dtMaximo;

    this.id = 0;
    this.anterior = null;
    this.continuo = false;        // true: anima; false: solo dibuja al invalidar
    this.pendiente = false;       // hay un dibujo bajo demanda programado
    this.visible = true;
    this.enPantalla = true;

    this.alCambiarVisibilidad = () => {
      this.visible = !document.hidden;
      this.revisar();
    };
    document.addEventListener('visibilitychange', this.alCambiarVisibilidad);

    this.observador = new IntersectionObserver(entradas => {
      this.enPantalla = entradas[0].isIntersecting;
      this.revisar();
    }, { threshold: 0 });
    this.observador.observe(elemento);
  }

  get puedeCorrer() { return this.visible && this.enPantalla; }

  animar(activo = true) {
    this.continuo = activo;
    this.revisar();
  }

  invalidar() {
    if (this.continuo || this.pendiente || !this.puedeCorrer) return;
    this.pendiente = true;
    requestAnimationFrame(() => {
      this.pendiente = false;
      this.dibujar(0);            // un unico fotograma, sin avanzar el estado
    });
  }

  revisar() {
    if (this.continuo && this.puedeCorrer) this.arrancar();
    else this.parar();
  }

  arrancar() {
    if (this.id) return;
    this.anterior = null;
    this.id = requestAnimationFrame(this.marco);
  }

  parar() {
    if (!this.id) return;
    cancelAnimationFrame(this.id);
    this.id = 0;
  }

  marco = ahora => {
    if (this.anterior === null) this.anterior = ahora;
    const dt = Math.min(this.dtMaximo, ahora - this.anterior);
    this.anterior = ahora;
    this.actualizar(dt);
    this.dibujar(dt);
    this.id = requestAnimationFrame(this.marco);
  };

  destruir() {
    this.parar();
    this.observador.disconnect();
    document.removeEventListener('visibilitychange', this.alCambiarVisibilidad);
  }
}

El uso cubre los dos escenarios con la misma pieza:

const bucle = new Bucle({
  elemento: canvas,
  actualizar: dt => actualizarMundo(mundo, dt),
  dibujar: () => dibujarMundo(ctx, mundo),
});

// Editor: quieto salvo que algo cambie
canvas.addEventListener('pointermove', e => {
  if (actualizarResaltado(e)) bucle.invalidar();
});

// Hay una transicion viva: animar hasta que termine
function empezarTransicion() {
  mundo.transicion = crearTransicion(0, 1, 0.3);
  bucle.animar(true);
}
function alTerminarTransicion() {
  bucle.animar(false);
  bucle.invalidar();               // un ultimo fotograma con el estado final
}

Ese último invalidar después de apagar la animación es fácil de olvidar y deja la escena congelada un fotograma antes del final, con un desplazamiento de uno o dos píxeles que no se explica hasta que se mira con lupa.

La lista de comprobación

Antes de dar por terminada cualquier animación de canvas, seis cosas que se olvidan en este orden de frecuencia:

El bucle se para al desmontar. En una aplicación con enrutado en el cliente, un bucle huérfano sigue corriendo sobre un canvas que ya no está en el documento, y su referencia impide liberar tanto el búfer como todo lo que el modelo alcance.

El arranque es idempotente. Llamarlo dos veces no duplica el bucle.

El delta está acotado y se reinicia al reanudar.

Se para cuando el canvas no está en pantalla.

Si la escena no es una animación, no hay bucle continuo.

El estado no se toca en dibujar, de modo que un fotograma extra por redimensionado o por invalidación no altere nada.

⚔️ Reto práctico

Instrumenta una escena con un contador de fotogramas dibujados por minuto y déjala corriendo diez minutos mientras haces vida normal en otras pestañas. Compara tres versiones: bucle continuo sin comprobaciones, con pausa por visibilidad, y con dibujo bajo demanda. La tercera columna suele estar dos órdenes de magnitud por debajo de la primera.