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.
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.
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 evitarlet id;function bucle() { id = requestAnimationFrame(bucle); renderer.render(scene, camera);}// cancelAnimationFrame(id) no garantiza que el frame en curso no termineEl 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.
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.