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.
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}import * as THREE from 'three';let hayQueDibujar = true;let fotogramasPendientes = 0;/** Marca la escena como sucia. Llámalo 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.