wandres.dev
PRIMER PROYECTO · Escena, cámara, renderer

El bucle de render: setAnimationLoop y por qué no tu propio rAF

Qué hace exactamente setAnimationLoop, por qué es obligatorio si algún día quieres realidad extendida, y cómo ordenar las fases de un fotograma.

⏱ 17 min

Escribir un bucle de animación con requestAnimationFrame es tan sencillo que parece innecesario usar otra cosa. Three.js ofrece setAnimationLoop, que hace lo mismo en el caso normal y algo distinto en un caso concreto, y ese caso concreto es lo bastante importante como para que la recomendación sea usarlo siempre. De paso, el orden de las fases dentro del fotograma no es indiferente y conviene fijarlo desde el primer proyecto.

🎯 Al terminar esta lección sabrás
  • Explicar qué hace setAnimationLoop además de programar el siguiente fotograma.
  • Justificar por qué un bucle propio rompe cualquier sesión de realidad extendida.
  • Ordenar correctamente las fases de entrada, actualización, sincronización y dibujado.
  • Implementar dibujado bajo demanda para escenas que no cambian todo el rato.

Qué hace de más que un rAF propio

renderer.setAnimationLoop(callback) programa una función para que se ejecute antes de cada repintado del navegador, igual que requestAnimationFrame. La diferencia está en de quién pide el fotograma.

En una página normal, el renderer usa el requestAnimationFrame de la ventana y el comportamiento es idéntico al de un bucle propio. Pero cuando hay una sesión de realidad extendida activa, el bucle debe venir de la sesión, no de la ventana: es la sesión la que conoce la frecuencia real del visor —que puede ser noventa o ciento veinte hercios—, la que sincroniza con su compositor, y la que entrega en cada fotograma el objeto con las poses de la cabeza y de los mandos. Un bucle basado en la ventana no recibe nada de eso y, en la mayoría de implementaciones, deja de ejecutarse al entrar en modo inmersivo.

setAnimationLoop cambia de fuente de fotogramas automáticamente al iniciarse y al terminar la sesión. Tu código no se entera. Ese es el motivo principal.

Hay dos más, menores pero reales. El primero es que el renderer guarda el identificador del fotograma pendiente, de modo que setAnimationLoop(null) detiene el bucle limpiamente sin que tengas que llevar la cuenta. El segundo es que el callback recibe dos argumentos: la marca de tiempo en milisegundos, con el mismo origen y precisión que la de requestAnimationFrame, y el objeto de fotograma de la sesión inmersiva cuando la haya.

import * as THREE from 'three';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(
  50, window.innerWidth / window.innerHeight, 0.1, 100
);
camera.position.z = 4;

const malla = new THREE.Mesh(
  new THREE.TorusKnotGeometry(0.7, 0.25, 128, 24),
  new THREE.MeshNormalMaterial()
);
scene.add(malla);

function fotograma(tiempo) {
  malla.rotation.x = tiempo / 2400;
  malla.rotation.y = tiempo / 1600;
  renderer.render(scene, camera);
}

renderer.setAnimationLoop(fotograma);

// Para detenerlo, por ejemplo al desmontar un componente.
// renderer.setAnimationLoop(null);

El orden de las fases

Un fotograma tiene cuatro fases y ejecutarlas en otro orden produce artefactos sutiles que cuesta mucho diagnosticar.

Entrada. Recoger el estado acumulado de teclado, ratón o mandos desde el fotograma anterior. Conviene acumular en los manejadores de eventos y leer aquí, no reaccionar directamente en el manejador: los eventos de puntero pueden llegar varias veces entre fotogramas y actuar en cada uno multiplica el movimiento.

Actualización. Avanzar el estado lógico de la aplicación con el tiempo transcurrido. Física, animación, lógica de juego, cámaras.

Sincronización. Volcar el estado lógico al grafo de escena: posiciones, rotaciones, visibilidad, materiales. Esta separación es la que permite que la lógica exista sin gráficos, y es la que recomendaba la primera lección del track.

Dibujado. renderer.render, que actualiza las matrices de mundo de toda la escena y emite las órdenes.

Y una quinta fase opcional, después del dibujado: cualquier lectura que dependa de matrices de mundo actualizadas, como colocar etiquetas de HTML sobre objetos. Hacerlo antes del render usa las matrices del fotograma anterior y produce un desfase de un fotograma.

function fotograma(tiempo) {
  const dt = reloj.actualizar(tiempo);

  procesarEntrada(estadoEntrada);       // 1
  actualizarMundo(mundo, dt);           // 2
  sincronizarEscena(mundo, scene);      // 3
  renderer.render(scene, camera);       // 4
  colocarEtiquetas(mundo, camera);      // 5
}
Dibujar sesenta veces por segundo una escena que no cambia es el defecto por defecto de casi todo el 3D web

La plantilla que todo el mundo copia dibuja en cada fotograma, para siempre, pase lo que pase. Para una escena animada es correcto. Para un configurador de producto, un visor de modelos, una pieza decorativa en una página o cualquier cosa que solo se mueve cuando el usuario interactúa, es un desperdicio continuo: la GPU trabaja a plena carga, el ventilador arranca y en un portátil sin enchufar la batería cae de forma perfectamente medible mientras el usuario lee un texto. El patrón correcto se llama dibujado bajo demanda y consiste en no programar trabajo cuando no lo hay: se mantiene una bandera que indica si algo ha cambiado, se dibuja solo si está activa y se baja al terminar. Cualquier cosa que modifique la escena la activa. Los controles de cámara emiten un evento de cambio al que basta suscribirse. Las animaciones en curso la mantienen activa mientras duran. La implementación cabe en diez líneas y el ahorro es del orden del noventa por ciento del tiempo de GPU en una página típica. Hay dos detalles que hacen que funcione bien y que casi nadie tiene en cuenta. El primero: hay que seguir programando fotogramas aunque no se dibuje, porque si detienes el bucle por completo necesitas una vía para reanudarlo desde cada punto de interacción, y eso se olvida en alguno; es más robusto mantener el bucle vivo y saltarse el render, cuyo coste sin dibujar es despreciable. El segundo: hay cambios que tardan un fotograma en surtir efecto —la carga de una textura, la compilación de un shader, la primera aparición de una sombra—, así que conviene marcar como sucios los dos fotogramas siguientes a un cambio y no solo uno. Con eso, una página con 3D deja de comportarse como un juego en segundo plano.

import * as THREE from 'three';

let hayQueDibujar = true;
let fotogramasPendientes = 0;

/** Marca la escena como sucia. Llamalo desde cualquier cambio. */
export function invalidar(fotogramas = 2) {
  fotogramasPendientes = Math.max(fotogramasPendientes, fotogramas);
  hayQueDibujar = true;
}

export function arrancar(renderer, scene, camera, actualizar) {
  renderer.setAnimationLoop((tiempo) => {
    const huboMovimiento = actualizar(tiempo);   // devuelve true si algo cambio
    if (huboMovimiento) invalidar();

    if (!hayQueDibujar) return;

    renderer.render(scene, camera);

    fotogramasPendientes -= 1;
    if (fotogramasPendientes <= 0) hayQueDibujar = false;
  });
}

// Los eventos que deben invalidar la escena.
window.addEventListener('resize', () => invalidar());
document.addEventListener('visibilitychange', () => invalidar());

No asumas sesenta hercios

Una última advertencia que pertenece al bucle y que condiciona toda la lección siguiente. La frecuencia a la que se ejecuta el callback la decide el navegador según la pantalla y la carga del sistema. Hay monitores a ciento veinte y a ciento cuarenta y cuatro hercios, portátiles que bajan a treinta en modo de ahorro, y visores inmersivos a noventa. Además, el navegador suspende los fotogramas por completo cuando la pestaña deja de ser visible.

Cualquier código que sume una cantidad fija por fotograma —rotation.y += 0.01— se ejecuta al doble de velocidad en un monitor de ciento veinte hercios y a la mitad en uno de treinta. Es el error más común de todos los que empiezan y el más fácil de arreglar: basta con multiplicar por el tiempo transcurrido, que es el tema de la lección sobre delta time. Lo menciono aquí porque la tentación de escribir el incremento fijo aparece exactamente en este punto, al escribir el primer bucle.