Qué hace un motor de render y qué no hace
La frontera exacta entre el trabajo que Three.js asume y el que sigue siendo tuyo, y por qué casi todos los malentendidos del 3D en la web nacen de colocar esa frontera en el sitio equivocado.
Un motor de render no dibuja nada. Dibuja la GPU; el motor traduce una descripción de escena a una secuencia de órdenes que la GPU sabe ejecutar, y lo hace intentando emitir el menor número posible de esas órdenes. Toda la biblioteca cabe en esa frase, y entenderla de entrada te ahorra la clase de expectativa que hunde proyectos: esperar que Three.js resuelva la física, el streaming de mundo o la memoria de vídeo porque ya resolvió las matrices.
- Definir qué es un motor de render en términos de responsabilidades, no de funciones.
- Enumerar las cinco responsabilidades que Three.js asume y las que deja fuera.
- Distinguir la capa de simulación de la capa de presentación en una aplicación 3D.
- Explicar por qué los recursos de GPU no los libera el recolector de basura.
Un traductor entre dos mundos que no se parecen
La GPU no entiende de escenas. Entiende de búferes de números, programas compilados, estados fijos y órdenes de dibujo. Una llamada de dibujo dice, más o menos: «con este programa, estos búferes de vértices, estas texturas, este estado de mezcla y esta configuración de profundidad, procesa estos 36 índices». No hay cubos, no hay materiales, no hay padres ni hijos. Esos conceptos existen en tu cabeza y en el motor, no en el hardware.
Tu código, en cambio, quiere hablar de objetos: una malla con un material metálico, dentro de un grupo que rota, iluminada por dos luces, vista desde una cámara con un campo de visión de 50 grados. Entre esos dos vocabularios hay una distancia enorme, y el motor de render es exactamente el código que la recorre. Cada fotograma toma tu descripción declarativa del mundo y produce una lista imperativa de órdenes.
La parte interesante no es que haga la traducción, sino que la haga rápido. Una traducción ingenua emitiría una orden por objeto, cambiaría de programa y de textura en cada una, y recalcularía todo desde cero. Un motor decente hace lo contrario: descarta lo que no se ve, agrupa lo que comparte estado, ordena para que la GPU trabaje menos y reutiliza todo lo que puede reutilizar entre fotogramas. Casi toda la complejidad interna de Three.js es esa optimización, no la geometría.
Las cinco responsabilidades que Three.js asume
Ciclo de vida de los recursos de GPU. Cuando creas una BufferGeometry con un atributo de posiciones, ese array vive en la memoria de JavaScript. El renderer detecta en el primer dibujado que no existe todavía un búfer de GPU asociado, lo crea, sube los datos y guarda la correspondencia en una tabla interna. Lo mismo con texturas y con programas. Tú nunca llamas a gl.createBuffer, y esa es la mitad del valor de la biblioteca.
Ensamblado y caché de shaders. MeshStandardMaterial no es un shader: es una plantilla parametrizada. Según el material tenga o no mapa de normales, mapa de rugosidad, niebla, sombras, instanciación o skinning, el motor compone un shader distinto a partir de fragmentos reutilizables y lo compila una única vez por combinación. Cada combinación es una permutación, y el número de permutaciones que activa tu escena es una métrica real de coste de arranque.
Gestión de estado y orden de dibujo. Antes de cada orden hay que dejar la GPU en un estado concreto: qué programa está activo, qué texturas en qué unidades, si la prueba de profundidad escribe o solo lee, qué función de mezcla se aplica. Cambiar de estado es caro, así que el motor recuerda el estado actual y evita los cambios redundantes. Además ordena: los objetos opacos se dibujan de delante a atrás, para que la prueba de profundidad descarte pronto lo que quedará tapado; los transparentes, de atrás a delante, porque la mezcla no es conmutativa y el orden cambia el resultado.
Descarte por frustum. Todo objeto con frustumCulled activo se prueba contra el volumen visible de la cámara usando su esfera envolvente, y si queda fuera no se emite ninguna orden. Es la optimización con mejor relación entre coste y beneficio que existe, y viene puesta por defecto.
Gestión del color. Desde que ColorManagement está activo por defecto, el motor mantiene una separación estricta entre el espacio de trabajo, que es lineal, y el espacio de salida, que es sRGB. Las texturas de color se convierten a lineal al leerlas y el resultado se convierte a sRGB al escribirlo. Sin esa disciplina, cualquier suma de luces da resultados físicamente incorrectos.
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(800, 600);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(50, 800 / 600, 0.1, 100);
camera.position.set(0, 0, 5);
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshNormalMaterial();
for (let i = 0; i < 40; i++) {
const cubo = new THREE.Mesh(geometry, material);
cubo.position.set(
THREE.MathUtils.randFloatSpread(8),
THREE.MathUtils.randFloatSpread(8),
THREE.MathUtils.randFloatSpread(8)
);
scene.add(cubo);
}
renderer.render(scene, camera);
// El trabajo que acaba de hacer el motor, en numeros.
console.log('geometrias en GPU:', renderer.info.memory.geometries);
console.log('texturas en GPU:', renderer.info.memory.textures);
console.log('programas compilados:', renderer.info.programs.length);
console.log('ordenes de dibujo:', renderer.info.render.calls);
console.log('triangulos:', renderer.info.render.triangles);
Ese ejemplo tiene cuarenta mallas y produce una sola geometría en GPU y un solo programa compilado, porque las cuarenta comparten instancia de geometría y de material. Las órdenes de dibujo, en cambio, serán tantas como cubos queden dentro del frustum. Esa asimetría —memoria compartida, órdenes no compartidas— es el primer hecho de rendimiento que hay que interiorizar.
Quitar una malla de la escena con remove no libera un solo byte de la GPU. Ni siquiera perder todas las referencias en JavaScript la libera: el recolector puede recoger el objeto BufferGeometry, pero el búfer de WebGL que le corresponde vive en el driver, y el motor lo tenía anotado en una tabla interna asociada a esa geometría. Cuando el objeto desaparece sin haber avisado, la tabla usa referencias débiles y el búfer se queda huérfano hasta que el contexto muera. Por eso existen geometry.dispose(), material.dispose() y texture.dispose(): no son cortesía, son la única forma de que el motor emita la orden de borrado. La regla operativa es que la propiedad de un recurso de GPU es tuya y explícita, y el síntoma de haberla ignorado es un contador de renderer.info.memory que solo sube durante una sesión larga, seguido de una caída de rendimiento cuando el driver empieza a paginar VRAM contra memoria del sistema. Instrumenta esos dos contadores en desarrollo desde el primer día del proyecto: son cuatro líneas y detectan la fuga meses antes de que se note.
Lo que no hace, y que mucha gente da por hecho
No hay física. No hay colisiones, ni cuerpos rígidos, ni gravedad, ni respuesta al impacto. Hay ayudas geométricas —Box3, Sphere, Ray, Plane— que sirven para consultas, pero una consulta de intersección no es un simulador. La física se integra con una biblioteca externa, y esa integración tiene su propio nivel más adelante en este track.
No hay sistema de entidades ni de juego. Three.js te da un árbol de nodos con transformación, no un modelo de componentes, ni máquinas de estado, ni sistema de eventos de juego, ni orden de actualización. Si construyes algo complejo, esa arquitectura la pones tú encima.
No hay pipeline de assets. El motor carga glTF, texturas y algunos formatos más, pero no comprime, no genera niveles de detalle, no atlasa texturas ni valida presupuestos de triángulos. Eso ocurre antes, en tu proceso de compilación, con herramientas separadas.
No hay descarte por oclusión. El frustum culling descarta lo que está fuera de cámara; lo que está dentro de cámara pero tapado por una pared se dibuja igualmente. Resolver eso es trabajo de aplicación.
Y no hay gestión de memoria automática, como acabas de leer.
La lista no es una crítica. Es una decisión de diseño coherente: Three.js es una biblioteca de renderizado, no un motor de juego. La comparación honesta no es con Unity, sino con la capa de renderizado de Unity. Confundir ambas cosas produce dos errores simétricos e igual de caros: buscar en la documentación funciones que nunca existieron, o reimplementar mal cosas que sí existen.
Dónde queda tu código
De ahí sale la única decisión de arquitectura que importa al empezar: separa el estado del mundo del estado del render.
Tu aplicación tiene un modelo —dónde está cada cosa, qué está haciendo, qué ha pasado— que podría existir sin gráficos. Y tiene una representación —qué malla corresponde a qué entidad, con qué material, en qué posición del grafo— que es puro Three.js. Si mezclas ambos, acabas con la lógica de negocio dentro del bucle de render, leyendo mesh.position como si fuera la verdad. Funciona hasta que necesitas pausar, rebobinar, sincronizar por red, hacer pruebas sin GPU o cambiar de renderer.
El patrón sano es un bucle que actualiza el modelo, después sincroniza el grafo de escena contra el modelo, y por último dibuja. Las tres fases con fronteras claras. Con ese esqueleto, cambiar WebGLRenderer por WebGPURenderer toca una línea, y sustituir la representación entera de un objeto no toca la lógica.
El mapa completo del territorio al final de este nivel coloca cada tema del track en su sitio; antes conviene ver qué capas hay debajo de Three.js, porque la frontera que acabas de leer se entiende mucho mejor cuando sabes qué hay al otro lado.