El bucle de render frente al ciclo de vida del componente
Por qué el bucle de sesenta hercios y el ciclo de renderizado del framework son dos relojes distintos, cómo comunicarlos sin provocar re-renders, y qué hacer con las closures obsoletas.
Dos relojes que no deben sincronizarse
El reloj del framework avanza por eventos: un clic, una respuesta de red, un cambio de ruta. Puede pasar un minuto sin un solo tic. El reloj del render avanza por fotogramas, a cadencia fija, independientemente de si algo ha cambiado.
Intentar que el segundo dispare el primero es catastrófico. Un setState dentro del bucle de render provoca una reconciliación por fotograma; con un árbol de componentes mediano eso es más trabajo de JavaScript que toda la escena 3D junta, y el resultado es una aplicación que va a quince fotogramas mientras el perfilador de la GPU dice que está ociosa.
La dirección correcta del flujo es esta: el framework escribe en un objeto mutable, el bucle lo lee. Nada vuelve en sentido contrario salvo eventos discretos y poco frecuentes, y esos van por un canal aparte.
// El estado que el bucle lee. No es reactivo a proposito.const control = { velocidad: 1, color: new THREE.Color(0xffffff), objetivo: new THREE.Vector3(), pausado: false,};// El bucle solo leefunction frame(marca) { reloj.update(marca); // el reloj avanza aunque la escena este pausada if (control.pausado) return; const dt = reloj.getDelta(); malla.rotation.y += dt * control.velocidad; malla.material.color.copy(control.color); renderer.render(scene, camera);}El objeto control es la frontera. El framework le escribe cuando el usuario mueve un deslizador; el bucle lo lee sesenta veces por segundo. Ni el framework se entera de los fotogramas ni el bucle provoca renderizados de componentes. Es una frontera de una sola dirección, y por eso funciona.
La closure obsoleta
En React, el problema clásico se presenta así:
// MAL: el bucle captura el valor de velocidad del primer renderfunction Escena() { const [velocidad, setVelocidad] = useState(1); const contenedor = useRef(null); useEffect(() => { const api = crearEscena(contenedor.current); api.setBucle(() => { malla.rotation.y += 0.016 * velocidad; // siempre 1 }); return () => api.destruir(); }, []); // sin velocidad en dependencias return <div ref={contenedor} />;}El efecto se ejecuta una vez, la función del bucle captura velocidad con su valor inicial, y mover el deslizador no cambia nada. La reacción instintiva es añadir velocidad a las dependencias, y ahí empieza el desastre: cada cambio de velocidad destruye la escena entera y la vuelve a crear, con su recarga de texturas y su recompilación de shaders.
La solución correcta usa una referencia mutable, que es exactamente el objeto control de antes con la sintaxis del framework.
function Escena() { const [velocidad, setVelocidad] = useState(1); const contenedor = useRef(null); const control = useRef({ velocidad: 1 }); // Sincroniza el estado reactivo con la referencia que lee el bucle useEffect(() => { control.current.velocidad = velocidad; }, [velocidad]); useEffect(() => { const api = crearEscena(contenedor.current, control.current); return () => api.destruir(); }, []); return ( <> <div ref={contenedor} /> <input type="range" min="0" max="5" step="0.1" value={velocidad} onChange={(e) => setVelocidad(Number(e.target.value))} /> </> );}El efecto de sincronización cuesta una asignación y se ejecuta solo cuando el valor cambia. La escena se crea una vez. Y el bucle lee siempre el valor vigente porque lee de un objeto, no de una variable capturada.
Lo que sí tiene que subir del bucle al framework
No todo el flujo es de bajada. Hay información que nace en la escena y tiene que llegar a la interfaz: qué objeto está bajo el cursor, si la carga terminó, si el usuario ha llegado al final de un recorrido. La regla es que esos eventos son discretos y con guarda, nunca continuos.
let ultimoObjetoBajoCursor = null;function frame() { raycaster.setFromCamera(punteroNDC, camera); const impactos = raycaster.intersectObjects(interactivos, false); const actual = impactos.length > 0 ? impactos[0].object : null; // La guarda es todo: solo notifica cuando el valor cambia de verdad if (actual !== ultimoObjetoBajoCursor) { ultimoObjetoBajoCursor = actual; control.alPasarPorEncima?.(actual ? actual.name : null); } renderer.render(scene, camera);}Sin la comparación, el callback se llama sesenta veces por segundo con el mismo valor y el componente se reconcilia sesenta veces por segundo. Con ella, se llama cuando el cursor cruza un borde, que es como mucho unas pocas veces por segundo. La diferencia entre las dos versiones puede ser el frame entero.
Cuando lo que sube es un valor genuinamente continuo, como un porcentaje de progreso de una animación, no lo lleves al estado. Escríbelo directamente en el DOM.
const barra = document.querySelector('#progreso');function frame() { // Escritura directa: cero reconciliaciones barra.style.setProperty('--avance', String(recorrido.t)); renderer.render(scene, camera);}Actualizar una propiedad personalizada de CSS desde el bucle es rapidísimo y no involucra al framework. El componente sigue siendo el dueño del elemento; solo estás pintando dentro de él.
Pausar, reanudar y el salto de tiempo
El bucle tiene que parar en tres situaciones: cuando el componente se desmonta, cuando el canvas sale del viewport, y cuando la pestaña pasa a segundo plano. Las tres se detectan con APIs distintas, y las tres desembocan en el mismo problema al volver.
export function gestionarActividad(renderer, frame, contenedor, reloj) { let visibleEnPantalla = true; let pestanaActiva = !document.hidden; function evaluar() { const debeCorrer = visibleEnPantalla && pestanaActiva; if (debeCorrer) { // Descarta el tiempo acumulado durante la pausa. // Sin esto, el primer frame aplica un delta gigante. reloj.reset(); renderer.setAnimationLoop(frame); } else { renderer.setAnimationLoop(null); } } const io = new IntersectionObserver(([e]) => { visibleEnPantalla = e.isIntersecting; evaluar(); }); io.observe(contenedor); function alCambiarVisibilidad() { pestanaActiva = !document.hidden; evaluar(); } document.addEventListener('visibilitychange', alCambiarVisibilidad); evaluar(); return () => { io.disconnect(); document.removeEventListener('visibilitychange', alCambiarVisibilidad); renderer.setAnimationLoop(null); };}La llamada a reloj.reset() justo antes de reanudar es la línea que evita el salto. Mientras el bucle estuvo parado nadie llamó a update(), así que el intervalo pendiente abarca toda la pausa: si han pasado treinta segundos, el primer delta será treinta. Una física integrada con ese delta explota; una rotación con ese delta da un giro aleatorio; un lerp con ese delta salta al destino. Descartarlo cuesta una línea.
Conviene tener claro qué cubre cada mecanismo, porque aquí hay tres solapados. reloj.connect(document) ya se ocupa por su cuenta del caso de la pestaña oculta: instala su propio escucha de visibilitychange y hace el reset() al volver. Lo que no cubre es el otro disparador de esta función, el canvas que sale del viewport, porque eso no es un cambio de visibilidad del documento. Por eso el reset() explícito sigue haciendo falta.
Y falta el tercero: incluso con el descarte, conviene acotar el delta en el propio bucle. Un fotograma que tarda medio segundo por una recolección de basura o por una compilación de shader produce un delta de medio segundo, y el efecto es el mismo salto en pequeño.
function frame(marca) { reloj.update(marca); const dt = Math.min(reloj.getDelta(), 1 / 30); // techo de 33 ms actualizar(dt); renderer.render(scene, camera);}El techo hace que en un fotograma lento la escena avance en cámara lenta en lugar de saltar. Es preferible casi siempre, y es obligatorio si hay física o colisiones, porque un salto grande atraviesa paredes.
El presupuesto de JavaScript por fotograma
Todo lo anterior existe por una razón cuantitativa. A sesenta fotogramas por segundo tienes 16,6 milisegundos por fotograma para todo: tu lógica, el recorrido del grafo de escena que hace Three, las llamadas de dibujo, y el trabajo del navegador. La escena en sí, en el hilo principal, suele consumir entre dos y cinco milisegundos en actualizar matrices y emitir llamadas.
Eso deja del orden de diez milisegundos de margen. Una reconciliación de un árbol de componentes de tamaño medio cuesta entre uno y cinco. Ejecutarla por fotograma se come la mitad del presupuesto para no mostrar nada nuevo, porque el valor que cambió ya lo estaba leyendo el bucle directamente.
La prueba de que has hecho la integración bien es que el panel de rendimiento del navegador, con la escena en movimiento y sin interacción, no muestre ni una sola tarea del framework. Si aparecen barras de reconciliación en cada fotograma, hay un setState en el bucle, y encontrarlo es más urgente que cualquier optimización de la escena.