El patrón de montaje y desmontaje de una escena
Cómo encapsular una escena de Three.js en una unidad que se monta sobre un contenedor y se desmonta sin dejar rastro, con redimensionado correcto y sin depender del framework.
Una escena de Three.js escrita como un script suelto funciona perfectamente hasta el día en que tiene que convivir con una aplicación. Entonces aparecen las preguntas incómodas: quién es el dueño del canvas, qué pasa cuando el usuario navega a otra ruta, quién para el bucle de render, y por qué al volver atrás hay dos escenas dibujando a la vez. La respuesta no es “usar un framework de 3D”: es diseñar la escena como una unidad con un contrato de dos funciones, montar y destruir, que cualquier framework pueda invocar.
- Encapsular una escena en una función que recibe un contenedor y devuelve un objeto con
destruir. - Redimensionar correctamente con
ResizeObserveren lugar del eventoresizede la ventana. - Arrancar y parar el bucle con
setAnimationLoopy entender por qué no valerequestAnimationFrame. - Diagnosticar el síntoma de dos bucles simultáneos y saber de dónde viene.
El contrato: un contenedor entra, un destructor sale
La forma que mejor sobrevive al cambio de framework es una factoría. Recibe el elemento del DOM donde va a vivir, crea todo lo suyo, y devuelve un objeto con las operaciones que el exterior necesita. Nada global, nada colgado de window, y ningún acceso a document.body.
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
/**
* Crea una escena montada sobre un contenedor del DOM.
* @param {HTMLElement} contenedor
* @returns {{ destruir: () => void, pausar: () => void, reanudar: () => void }}
*/
export function crearEscena(contenedor) {
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
contenedor.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
camera.position.set(0, 1.5, 4);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
const geometria = new THREE.TorusKnotGeometry(0.7, 0.25, 128, 32);
const material = new THREE.MeshStandardMaterial({ roughness: 0.3 });
const malla = new THREE.Mesh(geometria, material);
scene.add(malla);
scene.add(new THREE.HemisphereLight(0xffffff, 0x334455, 2));
const reloj = new THREE.Timer();
reloj.connect(document);
function ajustar() {
const { clientWidth: w, clientHeight: h } = contenedor;
if (w === 0 || h === 0) return;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h, false);
}
const observador = new ResizeObserver(ajustar);
observador.observe(contenedor);
ajustar();
function frame(marca) {
reloj.update(marca);
const dt = reloj.getDelta();
malla.rotation.y += dt * 0.5;
controls.update();
renderer.render(scene, camera);
}
renderer.setAnimationLoop(frame);
return {
pausar() {
renderer.setAnimationLoop(null);
},
reanudar() {
reloj.reset(); // descarta el salto acumulado mientras estaba parado
renderer.setAnimationLoop(frame);
},
destruir() {
renderer.setAnimationLoop(null);
observador.disconnect();
reloj.dispose(); // quita el escucha de visibilitychange que puso connect
controls.dispose();
geometria.dispose();
material.dispose();
renderer.dispose();
renderer.domElement.remove();
},
};
}
Esta función se puede montar desde React, desde Solid, desde Svelte, desde un <script> en un HTML plano o desde un test. El framework solo aporta el momento del montaje y el momento del desmontaje, que es exactamente lo que un framework sabe hacer bien.
Fíjate en que destruir es incompleta a propósito: libera lo que esta función creó explícitamente, pero no recorre el grafo buscando recursos. Para una escena de tres objetos es suficiente. Para una escena real, con modelos cargados y texturas, hace falta la limpieza completa que verás en dispose de verdad.
Por qué ResizeObserver y no el evento resize
El evento resize de la ventana se dispara cuando cambia el tamaño de la ventana. Tu canvas casi nunca ocupa la ventana entera: vive dentro de un contenedor que puede cambiar de tamaño por muchísimas más razones. Una barra lateral que se pliega, un panel que se abre, un cambio de ruta que altera el layout, una fuente que carga tarde y desplaza el contenido, un elemento con container-type que reacciona a su propio padre. Ninguno de esos casos dispara resize.
ResizeObserver observa el elemento, no la ventana, y se dispara en todos esos casos. Además llega antes del pintado, lo que evita el fotograma con el canvas deformado que se ve con resize.
Dos detalles importantes. El primero es la guarda de tamaño cero: un contenedor con display: none reporta cero por cero, y dividir por cero al calcular aspect produce una matriz de proyección con NaN que deja la escena en negro para siempre, incluso después de volver a mostrarla. La comprobación de dos líneas evita un bug que puede costar una tarde.
El segundo es el tercer argumento de setSize. Con false, Three cambia el tamaño del buffer de render pero no toca los estilos en línea del canvas. Eso permite que el canvas se dimensione por CSS con width: 100% y height: 100%, que es lo que quieres cuando vive dentro de un layout. Con el valor por defecto, Three escribe style.width y style.height en píxeles y pelea con tu CSS.
Si tu escena está dentro de un contenedor con position: relative, dale al canvas display: block en CSS. Por defecto el canvas es inline, lo que le añade el espacio de la línea base y provoca una barra de desplazamiento de cuatro píxeles que aparece y desaparece según el redondeo. Es el bug de maquetación más tonto y más frecuente del 3D en la web.
setAnimationLoop y por qué no requestAnimationFrame
renderer.setAnimationLoop(fn) parece un envoltorio cosmético sobre requestAnimationFrame, y en el navegador normal lo es. La diferencia aparece en dos escenarios.
El primero es WebXR. Cuando hay una sesión inmersiva activa, los fotogramas no los programa la ventana: los programa el dispositivo, a través de session.requestAnimationFrame, y llegan a la cadencia del visor, que puede ser noventa o ciento veinte hercios. setAnimationLoop cambia de fuente automáticamente al entrar y salir de la sesión. Un bucle escrito con requestAnimationFrame simplemente no dibuja nada dentro del visor.
El segundo es la parada limpia. setAnimationLoop(null) detiene el bucle de forma atómica. Con requestAnimationFrame hay que guardar el identificador y cancelarlo, y en la práctica siempre queda un fotograma en vuelo que se ejecuta después de haber liberado recursos. Ese fotograma tardío accediendo a una geometría ya destruida es el origen de errores de WebGL que aparecen solo al navegar rápido entre rutas.
// Lo que hay que evitar
let id;
function bucle() {
id = requestAnimationFrame(bucle);
renderer.render(scene, camera);
}
// cancelAnimationFrame(id) no garantiza que el frame en curso no termine
El callback de setAnimationLoop recibe dos argumentos: la marca de tiempo y, en sesión XR, el XRFrame del fotograma. En el navegador normal el segundo es undefined, y esa es justo la forma idiomática de saber si estás dentro de una sesión inmersiva sin consultar el estado del renderer.
El bug de los dos bucles
El síntoma es característico: la escena va exactamente al doble de velocidad, o el ventilador se dispara, o el contador de fotogramas marca sesenta pero el movimiento va a tirones. Casi siempre significa que hay dos escenas vivas a la vez.
Las causas habituales son tres.
La primera es el modo estricto de React en desarrollo, que monta, desmonta y vuelve a montar cada efecto para detectar limpiezas incorrectas. Si tu efecto crea la escena pero la función de limpieza no la destruye del todo, en desarrollo acabas con dos. No es un fallo de React: es React haciendo visible tu fallo. La solución no es desactivar el modo estricto, es que destruir haga su trabajo.
La segunda es un montaje que no espera al desmontaje. En una navegación de cliente, el componente nuevo puede montarse antes de que el viejo se limpie. Si ambos escriben en la misma variable de módulo, el destructor del viejo destruye la escena del nuevo o, peor, no destruye nada. Por eso la factoría no debe guardar estado en variables de módulo: todo lo suyo vive en el cierre.
La tercera es un setAnimationLoop llamado dos veces sin haberlo puesto a null entre medias. Aquí Three te protege parcialmente, porque solo hay un callback activo por renderer, pero si tienes dos renderers tienes dos bucles.
Hay una razón poco conocida para que el desmontaje sea agresivo y no perezoso: los navegadores limitan el número de contextos WebGL simultáneos, en el orden de ocho a dieciséis según el navegador y la plataforma. Cuando se supera el límite, el navegador no lanza un error: destruye silenciosamente el contexto más antiguo. Una aplicación con rutas que crean un renderer cada una y no lo destruyen funciona perfectamente durante las primeras ocho navegaciones y a la novena la primera escena se queda en negro. El síntoma llega tan tarde y tan lejos de la causa que se suele atribuir al navegador. Si vas a montar y desmontar escenas con frecuencia, considera reutilizar un único renderer y cambiar solo la escena y la cámara: es la arquitectura que evita el problema por construcción.
Pausar cuando no se ve
Una escena que dibuja mientras está fuera de la pantalla quema batería para nada. El navegador ya para requestAnimationFrame cuando la pestaña está en segundo plano, pero no cuando la pestaña está visible y tu canvas ha salido del viewport por desplazamiento.
const observadorVisibilidad = new IntersectionObserver(
([entrada]) => {
if (entrada.isIntersecting) escena.reanudar();
else escena.pausar();
},
{ threshold: 0 },
);
observadorVisibilidad.observe(contenedor);
Combinado con la llamada a reloj.reset() en reanudar, esto evita el segundo problema clásico: mientras el bucle estuvo parado nadie llamó a update(), así que al volver el primer intervalo abarca los treinta segundos de pausa y el primer fotograma aplica treinta segundos de rotación de golpe. Descartar ese tramo es una línea y ahorra un salto visible.
Para casos donde la escena es decorativa y estática, hay una tercera opción mejor que pausar: renderizar bajo demanda. En lugar de un bucle continuo, se llama a renderer.render solo cuando algo ha cambiado.
let pendiente = false;
function pedirRender() {
if (pendiente) return;
pendiente = true;
requestAnimationFrame(() => {
pendiente = false;
renderer.render(scene, camera);
});
}
controls.addEventListener('change', pedirRender);
Con OrbitControls hay que quitar enableDamping, porque el amortiguado necesita fotogramas continuos para converger. Es un intercambio consciente: pierdes la inercia y ganas una escena que consume cero cuando nadie la toca.
Monta la factoría de esta lección en una página con dos contenedores y crea dos escenas independientes. Comprueba que renderer.info.memory.geometries sube al montar y baja al destruir. Después destruye una de las dos y verifica en las herramientas de desarrollo que el canvas ha desaparecido del DOM y que el contador de contextos WebGL activos ha bajado. Si no baja, es que renderer.dispose() no basta y necesitas el paso siguiente.