wandres.dev
NIVEL DIOS · Síntesis del 3D en la web

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.

⏱ 23 min

Todo lo que has aprendido en cincuenta y dos niveles se junta en un fichero de arranque. La diferencia entre una demo y un producto no está en los shaders ni en los materiales: está en que el producto tiene un sitio para cada cosa, un momento definido para cada transición, y una forma de apagarse que no deja nada encendido. Esta lección es el andamiaje que sostiene todo lo demás, escrito como se escribe cuando ya se han cometido los errores.

🎯 Al terminar esta lección sabrás
  • Separar una aplicación 3D en capas con responsabilidades que no se solapen.
  • Implementar un registro de recursos que convierta la limpieza en una operación trivial.
  • Gobernar el arranque con una máquina de estados explícita en lugar de con banderas.
  • Definir el contrato mínimo que cualquier módulo de escena tiene que cumplir.

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.

flowchart TB
a[Capa de anfitrion] --> b[Capa de ciclo de vida]
b --> c[Capa de recursos]
c --> d[Capa de escena]
d --> e[Capa de render]
a2[HTML CSS framework isla] -.-> a
b2[Montaje pausa destruccion] -.-> b
c2[Carga registro y liberacion] -.-> c
d2[Grafo camaras interaccion] -.-> d
e2[Renderer passes presupuesto] -.-> e
style a fill:#cba6f7,color:#11111b
style b fill:#89b4fa,color:#11111b
style c fill:#94e2d5,color:#11111b
style d fill:#a6e3a1,color:#11111b
style e fill:#fab387,color:#11111b

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.

/**
 * 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:

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:

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

Devuelve el recurso desde anotar y conDispose. Ese detalle es lo que permite envolver la creación en línea, sin una variable intermedia y sin cambiar la forma del código. Un registro que obliga a escribir tres líneas donde antes había una no lo usa nadie a los dos meses.

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.

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:

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.

/**
 * @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:

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();
    },
  };
}
Nivel dios

La decisión arquitectónica con más impacto a largo plazo no es ninguna de las anteriores: es quién es el dueño del renderer. La opción intuitiva es que cada escena cree el suyo, y funciona hasta que la aplicación tiene varias rutas con 3D. Entonces te encuentras con el límite de contextos WebGL, con el coste de crear un contexto (que en móvil puede pasar de cien milisegundos), y con la recompilación completa de shaders en cada navegación. La alternativa es un renderer único que vive fuera de las escenas, propiedad de la aplicación, al que las escenas se conectan y desconectan. Cuesta más diseñar, porque obliga a que el estado del renderer se restablezca al cambiar de escena y a que las escenas no asuman nada sobre él, pero elimina de raíz tres clases de problemas y hace que cambiar de ruta sea instantáneo. Si sabes desde el principio que va a haber más de una escena, hazlo desde el principio: convertir un diseño de renderer por escena a renderer compartido cuando el proyecto ya está hecho es una refactorización que toca todo.

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.

⚔️ Reto práctico

Coge tu proyecto 3D más grande y dibuja en qué capa cae cada fichero. Los que caigan en dos capas a la vez son los que te están costando tiempo cada vez que tocas algo. Después implementa el registro de recursos y migra un solo módulo: la reducción de la función de limpieza suele ser de cuarenta líneas a una, y esa comparación convence más que cualquier argumento.