Saltar al contenido

La arquitectura de una experiencia 3D, de principio a fin

Las cinco capas de una aplicación 3D real, dónde vive cada responsabilidad, el registro de recursos que hace la limpieza trivial, y la máquina de estados que gobierna el arranque.

Las cinco capas

Una experiencia 3D de tamaño real tiene cinco responsabilidades que conviene no mezclar. No son carpetas obligatorias ni una arquitectura con nombre: son cinco preguntas distintas, y cuando una función responde a dos a la vez es cuando el proyecto empieza a doler.

La capa de anfitrión es todo lo que no es 3D: el HTML, el CSS, el framework, la ruta. Su única responsabilidad es proporcionar un contenedor con un tamaño y decir cuándo empieza y cuándo termina. No sabe nada de Three.js, y esa ignorancia es lo que permite cambiar de framework sin tocar nada más.

La capa de ciclo de vida traduce los eventos del anfitrión en operaciones sobre la escena: montar, pausar, reanudar, destruir. Es donde vive el ResizeObserver, el IntersectionObserver y el manejo de la pérdida de contexto. Es genérica: la misma para todas tus escenas.

La capa de recursos carga, registra y libera. Es la que hace que la limpieza sea trivial, y la desarrollamos en la sección siguiente.

La capa de escena es el contenido: el grafo, las cámaras, las luces, la interacción, la lógica. Es la única capa que cambia entre proyectos, y por eso todo lo demás debe ser reutilizable.

La capa de render decide cómo se dibuja: qué renderer, qué pasadas, con qué presupuesto. Está separada de la escena porque la misma escena tiene que poder dibujarse a media resolución en un móvil y con post-procesado en un escritorio.

El registro de recursos

La técnica que más simplifica una aplicación 3D es también la más sencilla: un objeto que apunta lo que hay que liberar en el momento en que se crea, y lo libera todo de golpe al final.

JS
/** * Registro de recursos. Todo lo que se crea se apunta aqui, * y destruir libera en orden inverso al de creacion. */export function crearRegistro() {  const acciones = [];  return {    /** Apunta una funcion de limpieza y devuelve el recurso */    anotar(recurso, liberar) {      acciones.push(liberar);      return recurso;    },    /** Atajo para cualquier objeto de Three con dispose() */    conDispose(recurso) {      acciones.push(() => recurso.dispose());      return recurso;    },    /** Atajo para escuchadores de eventos */    escuchar(objetivo, tipo, manejador, opciones) {      objetivo.addEventListener(tipo, manejador, opciones);      acciones.push(() => objetivo.removeEventListener(tipo, manejador, opciones));    },    /** Atajo para observadores */    observador(obs) {      acciones.push(() => obs.disconnect());      return obs;    },    /** Libera todo en orden inverso */    destruir() {      for (let i = acciones.length - 1; i >= 0; i--) {        try {          acciones[i]();        } catch (e) {          console.warn('fallo al liberar un recurso', e);        }      }      acciones.length = 0;    },  };}

El orden inverso no es un detalle estético. Los recursos se crean con dependencias (el material necesita la textura, el composer necesita el renderer) y liberarlos en el orden de creación puede dejar referencias colgando. Liberar en orden inverso deshace la construcción exactamente como se hizo.

El try por acción es lo que evita que un fallo en la liberación de un recurso impida liberar los demás. En una limpieza es preferible un aviso en consola y continuar que un error que deja media escena en la GPU.

El uso convierte la construcción en algo que se limpia solo:

JS
export function construirEscena(contenedor) {  const reg = crearRegistro();  const renderer = reg.anotar(    new THREE.WebGLRenderer({ antialias: true }),    () => {      renderer.setAnimationLoop(null);      renderer.dispose();      renderer.domElement.remove();    },  );  contenedor.appendChild(renderer.domElement);  const geometria = reg.conDispose(new THREE.TorusKnotGeometry(0.7, 0.25, 128, 32));  const material = reg.conDispose(new THREE.MeshStandardMaterial());  const scene = new THREE.Scene();  scene.add(new THREE.Mesh(geometria, material));  const camera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);  camera.position.z = 4;  const controls = reg.conDispose(new OrbitControls(camera, renderer.domElement));  controls.enableDamping = true;  const ajustar = () => {    const { clientWidth: w, clientHeight: h } = contenedor;    if (!w || !h) return;    camera.aspect = w / h;    camera.updateProjectionMatrix();    renderer.setSize(w, h, false);  };  const ro = reg.observador(new ResizeObserver(ajustar));  ro.observe(contenedor);  ajustar();  reg.escuchar(renderer.domElement, 'webglcontextlost', (e) => {    e.preventDefault();    renderer.setAnimationLoop(null);  });  return {    scene,    camera,    renderer,    frame(dt) {      controls.update();      renderer.render(scene, camera);    },    destruir: reg.destruir,  };}

Cada línea que crea algo apunta su liberación en la misma línea. Es imposible olvidarse, porque olvidarse exige escribir una línea distinta a la que ya estás escribiendo. Esa es toda la idea, y elimina la categoría entera de fugas que costaba un nivel entero explicar.

Para el grafo cargado, el registro se combina con el recorrido completo:

JS
const gltf = await loader.loadAsync('/modelos/escena.glb');reg.anotar(gltf.scene, () => liberarSubarbol(gltf.scene));scene.add(gltf.scene);

La máquina de estados del arranque

El arranque de una experiencia 3D tiene más estados de los que parece, y gobernarlos con booleanos sueltos (cargando, listo, error, pausado) produce combinaciones imposibles que tarde o temprano ocurren. Una máquina de estados explícita es más corta y no tiene esos huecos.

JS
export const ESTADOS = {  INICIAL: 'inicial',  COMPROBANDO: 'comprobando',    // hay WebGL, hay rendimiento  CARGANDO: 'cargando',          // descargando recursos  PREPARANDO: 'preparando',      // compilando shaders, subiendo texturas  ACTIVO: 'activo',  PAUSADO: 'pausado',  DEGRADADO: 'degradado',        // funciona, pero con calidad reducida  ALTERNATIVA: 'alternativa',    // sin 3D, contenido estatico  ERROR: 'error',};const TRANSICIONES = {  inicial: ['comprobando'],  comprobando: ['cargando', 'alternativa'],  cargando: ['preparando', 'error', 'alternativa'],  preparando: ['activo', 'error'],  activo: ['pausado', 'degradado', 'error'],  pausado: ['activo', 'error'],  degradado: ['activo', 'pausado', 'alternativa'],  alternativa: [],  error: ['alternativa'],};export function crearMaquina(alCambiar) {  let actual = ESTADOS.INICIAL;  return {    get estado() {      return actual;    },    ir(siguiente) {      if (!TRANSICIONES[actual].includes(siguiente)) {        console.warn(`transicion invalida: ${actual} -> ${siguiente}`);        return false;      }      const anterior = actual;      actual = siguiente;      alCambiar(actual, anterior);      return true;    },  };}

La tabla de transiciones vale por sí sola. Leerla contesta preguntas que en un proyecto sin ella se contestan discutiendo: desde alternativa no se sale (una vez decidido que no hay 3D, no se reintenta a mitad de sesión, porque el usuario ya está leyendo); desde error solo se va a alternativa (nunca se reintenta cargar en bucle); y degradado puede acabar en alternativa si ni siquiera el nivel mínimo llega.

El manejador de cambios es el sitio natural para conectar la interfaz:

JS
const maquina = crearMaquina((estado) => {  contenedor.dataset.estado = estado;  // Anuncio para tecnologias de asistencia  const mensajes = {    cargando: 'Cargando el visor tridimensional.',    preparando: 'Preparando los materiales.',    activo: 'Visor listo.',    alternativa: 'Se muestran imágenes en lugar del visor interactivo.',    error: 'No se ha podido cargar el visor.',  };  if (mensajes[estado]) regionViva.textContent = mensajes[estado];});

Un solo dataset.estado en el contenedor da al CSS todo lo que necesita para mostrar la pantalla de carga, la alternativa o el canvas, sin una sola clase añadida o quitada desde JavaScript.

El contrato del módulo de escena

Con las capas y el registro definidos, cualquier escena del proyecto cumple el mismo contrato. Cinco miembros, ni uno más.

JS
/** * @typedef {object} ModuloEscena * @property {THREE.Scene} scene * @property {THREE.Camera} camera * @property {(dt: number, tiempo: number, marcoXR?: XRFrame) => void} frame * @property {(w: number, h: number) => void} ajustar * @property {() => void} destruir */

frame recibe el delta ya acotado, el tiempo absoluto y el marco de XR si lo hay. No llama a renderer.render: eso es responsabilidad de la capa de render, que puede decidir renderizar directamente o a través de un composer. La escena solo actualiza su estado.

ajustar recibe el tamaño en píxeles CSS. No consulta el DOM ni la ventana: se lo dan. Eso hace que se pueda probar sin navegador y que funcione igual dentro de una isla que a pantalla completa.

Y el orquestador que junta todo es corto precisamente porque cada capa hace lo suyo:

JS
export async function arrancar(contenedor, crearModulo) {  const maquina = crearMaquina((e) => (contenedor.dataset.estado = e));  maquina.ir(ESTADOS.COMPROBANDO);  if (!WebGL.isWebGL2Available()) {    maquina.ir(ESTADOS.ALTERNATIVA);    return null;  }  const capa = crearCapaDeRender(contenedor);   // renderer, composer, presupuesto  maquina.ir(ESTADOS.CARGANDO);  let modulo;  try {    modulo = await crearModulo(capa);  } catch (e) {    console.error(e);    maquina.ir(ESTADOS.ERROR);    maquina.ir(ESTADOS.ALTERNATIVA);    return null;  }  maquina.ir(ESTADOS.PREPARANDO);  await capa.renderer.compileAsync(modulo.scene, modulo.camera, modulo.scene);  const ciclo = crearCicloDeVida(contenedor, capa, modulo, maquina);  maquina.ir(ESTADOS.ACTIVO);  return {    modulo,    destruir() {      ciclo.destruir();      modulo.destruir();      capa.destruir();    },  };}

Qué queda fuera a propósito

Esta arquitectura no incluye un sistema de entidades y componentes, ni un bus de eventos, ni inyección de dependencias. No porque estén mal, sino porque son respuestas a problemas que aparecen más tarde y con síntomas reconocibles.

Un sistema de entidades tiene sentido cuando hay muchos objetos con combinaciones variables de comportamiento y el árbol de herencia se vuelve inmanejable. Antes de eso, es indirección.

Un bus de eventos tiene sentido cuando hay módulos que deben reaccionar entre sí sin conocerse. Con tres módulos, llamarse directamente es más legible y más fácil de depurar.

Lo que sí conviene tener desde el primer día son las tres cosas de esta lección: las capas separadas, el registro de recursos y la máquina de estados. Las tres cuestan poco, se escriben una vez y evitan problemas que son caros de arreglar tarde.