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.
Un componente se renderiza cuando su estado cambia. Una escena 3D se renderiza sesenta veces por segundo pase lo que pase. Poner el segundo dentro del primero es el error estructural del que salen la mitad de los problemas de integración: valores congelados en una closure, estados que provocan reconciliaciones sesenta veces por segundo, y efectos que se recrean cada fotograma. La solución no es técnica sino de diseño: el bucle no debe leer el estado del framework, debe leer una referencia mutable que el framework actualiza.
- Distinguir los dos relojes y decidir qué información cruza de uno a otro y en qué dirección.
- Evitar closures obsoletas sin meter dependencias en el bucle de render.
- Sacar del bucle cualquier actualización de estado que provoque reconciliación.
- Gestionar la pausa por visibilidad y el salto de tiempo al reanudar.
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 lee
function 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 render
function 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.
Si el control es un valor que cambia continuamente, como la posición del ratón, ni siquiera pases por el estado del framework. Escribe directamente en la referencia desde el manejador de eventos. Guardar la posición del ratón en useState provoca una reconciliación por cada movimiento del puntero, que en una pantalla de alta frecuencia son ciento veinte por segundo, para un valor que ningún componente necesita mostrar.
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 modo estricto de React en desarrollo monta el efecto, lo desmonta y lo vuelve a montar de inmediato. Mucha gente lo trata como un estorbo y busca la forma de saltárselo con una bandera del tipo yaInicializado. Es exactamente el diagnóstico al revés: ese doble montaje está detectando que tu limpieza es incompleta, y la bandera esconde el fallo hasta que llega a producción como una fuga en cada navegación. Si tu escena sobrevive al doble montaje sin duplicar contextos ni bucles, sobrevivirá a cualquier patrón de navegación real. Trátalo como un test gratuito, no como un problema.
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.
Instrumenta una escena integrada en tu framework favorito con un contador de renders del componente y un contador de fotogramas. Muéstralos en la consola cada segundo. El objetivo es que el primero se quede en cero mientras la escena gira sola y el segundo marque sesenta. Si el primero acompaña al segundo, busca el setState que sobra: casi siempre está en un onPointerMove o en la notificación de un raycast sin guarda de cambio.