LOD: niveles de detalle
Cómo funciona la clase LOD de Three.js, qué hace exactamente su update, para qué sirve la histéresis, cómo se generan los niveles, y por qué el LOD no siempre reduce el coste.
Un objeto que ocupa cuarenta píxeles en pantalla no necesita cincuenta mil triángulos: la mayoría de sus caras son subpíxel y la GPU las procesa para descartarlas después. El nivel de detalle es la respuesta a esa observación y es la técnica más rentable de un mundo grande, porque el coste se reparte según lo que el espectador puede llegar a distinguir. Three.js trae una implementación pequeña y honesta, y conviene saber exactamente qué hace y qué no.
- Construir un
LODcon varios niveles y entender su criterio de selección. - Explicar qué hace
update( camera )y quién lo llama por defecto. - Usar la histéresis para eliminar el parpadeo en la frontera entre niveles.
- Generar niveles de detalle con
SimplifyModifiery evaluar cuándo el LOD no compensa.
La clase y su modelo
LOD es un Object3D que contiene varios hijos y decide cuál está visible según la distancia a la cámara.
import * as THREE from 'three';
const lod = new THREE.LOD();
lod.addLevel( mallaAlta, 0 ); // de 0 a 25 unidades
lod.addLevel( mallaMedia, 25 ); // de 25 a 80
lod.addLevel( mallaBaja, 80 ); // de 80 en adelante
lod.addLevel( impostor, 200 ); // muy lejos: un sprite
lod.position.set( 12, 0, - 30 );
scene.add( lod );
addLevel( objeto, distancia, histeresis ) inserta el nivel manteniendo el array ordenado por distancia y añade el objeto como hijo. La distancia es aquella a partir de la cual ese nivel se muestra, así que el primero debe ir a cero.
El criterio de selección está en update( camera ) y es más simple de lo que la gente supone: calcula la distancia entre la posición de la cámara y la posición del propio objeto LOD, la divide por camera.zoom, y activa el último nivel cuya distancia sea menor o igual.
// El calculo, en esencia:
const distancia = posicionCamara.distanceTo( posicionLOD ) / camera.zoom;
Dos consecuencias que hay que tener claras.
Es la distancia al origen del LOD, no al punto más cercano de su geometría. Para un objeto pequeño da igual. Para un edificio de cien metros cuyo origen está en su base, la cámara puede estar pegada a la fachada y el LOD calcular una distancia grande porque mide hasta el pivote. Solución: colocar el origen en el centro del volumen, o usar varios LOD para las partes.
No tiene en cuenta el tamaño del objeto ni el campo de visión. Un mismo umbral de 25 unidades es correcto para una silla y absurdo para una montaña. Las distancias hay que calibrarlas por objeto, idealmente en proporción a su tamaño: una regla decente es que el primer cambio ocurra cuando el objeto ocupe alrededor de un tercio de la altura de pantalla.
Y una consecuencia importante para el rendimiento: el LOD tampoco tiene en cuenta el fov. Si haces zoom reduciendo el campo de visión, un objeto lejano crece en pantalla pero su distancia no cambia, así que sigue mostrando el nivel bajo y se ve el defecto. En una cámara con zoom conviene escalar el umbral por la tangente del fov.
Actualizar los niveles
Quién llama a update es, por defecto, el renderer. Cuando lod.autoUpdate es true —su valor inicial— el proceso de proyección de objetos llama a update( camera ) antes de dibujar. No hay que hacer nada.
// Comportamiento por defecto: el renderer se encarga.
lod.autoUpdate = true;
// Control manual: util si renderizas con varias camaras.
lod.autoUpdate = false;
function animate() {
lod.update( camaraPrincipal );
renderer.render( scene, camaraPrincipal );
}
El control manual es necesario en dos situaciones. La primera, cuando renderizas la escena varias veces por frame —un mapa de sombras, un reflejo, un render target para post-proceso—: con autoUpdate activado, cada pase recalcula los niveles con su propia cámara, y el último en ejecutarse gana. Puedes acabar con un objeto renderizado a máximo detalle en un reflejo minúsculo. La segunda, cuando quieres tu propio criterio: tamaño en pantalla, presupuesto de triángulos, o distancia al jugador en lugar de a la cámara.
lod.getCurrentLevel() devuelve el índice activo, útil para depurar y para instrumentar. lod.getObjectForDistance( d ) devuelve qué objeto correspondería a esa distancia sin cambiar nada.
Un detalle que ahorra sorpresas: LOD implementa su propio raycast, que interseca solo el nivel correspondiente a la distancia del rayo. No interseca todos los niveles, lo cual sería incorrecto, ni siempre el de máxima calidad. Si necesitas picking preciso independientemente del nivel visible, tendrás que intersecar la malla de alta directamente.
Histéresis: el parpadeo en la frontera
El tercer argumento de addLevel resuelve un problema muy concreto. Si la cámara se queda exactamente en el umbral de 25 unidades y oscila un poco —por el amortiguamiento, por el ruido de la física, por un shake— el nivel cambia en cada frame y el objeto parpadea entre dos versiones.
// 10 % de histeresis: el umbral de subida y el de bajada no coinciden.
lod.addLevel( mallaMedia, 25, 0.1 );
El mecanismo es una banda muerta asimétrica. Cuando el nivel ya está visible, su distancia efectiva se reduce en esa fracción: con histéresis de 0,1, el nivel que se activa a 25 no se desactiva hasta bajar de 22,5. Hace falta recorrer esos 2,5 metros para volver atrás, y el parpadeo desaparece.
Valores entre 0,05 y 0,15 cubren casi todo. Más allá de 0,3 la asimetría es visible: el objeto cambia de nivel en sitios notablemente distintos según por dónde vengas.
Cada nivel es una malla completa con su material. Si cada nivel usa su propia instancia de MeshStandardMaterial, Three.js compila un programa por cada uno y el primer cambio de nivel produce un tirón de compilación. Comparte la misma instancia de material entre todos los niveles de un objeto siempre que puedas; solo el impostor lejano suele necesitar uno distinto.
Generar los niveles
Lo ideal es que los niveles vengan del artista o de una herramienta de la cadena de assets: gltf-transform incluye simplificación con meshoptimizer, que produce resultados muy superiores a cualquier cosa en tiempo de ejecución y además permite generar la extensión MSFT_lod dentro del propio glTF.
Cuando no hay más remedio, Three.js trae un simplificador:
import { SimplifyModifier } from 'three/addons/modifiers/SimplifyModifier.js';
const modificador = new SimplifyModifier();
function construirLOD( mallaAlta, material ) {
const lod = new THREE.LOD();
const geo = mallaAlta.geometry;
const vertices = geo.getAttribute( 'position' ).count;
lod.addLevel( mallaAlta, 0 );
// El segundo argumento es cuantos vertices ELIMINAR, no cuantos dejar.
const media = modificador.modify( geo, Math.floor( vertices * 0.6 ) );
lod.addLevel( new THREE.Mesh( media, material ), 30, 0.1 );
const baja = modificador.modify( geo, Math.floor( vertices * 0.9 ) );
lod.addLevel( new THREE.Mesh( baja, material ), 90, 0.1 );
return lod;
}
La firma sorprende: modify( geometry, count ) donde count es el número de vértices a eliminar. Para dejar el 40 %, hay que eliminar el 60 %.
Sus limitaciones son reales y conviene conocerlas antes de invertir tiempo: el algoritmo es de colapso de aristas por coste, es cuadrático en el número de vértices, no conserva morph targets, produce geometría no indexada, y no preserva bien las costuras de UV. Para geometría orgánica y sin texturas críticas funciona; para un modelo de producción con mapas cuidados, degrada visiblemente. Y para una malla de cien mil vértices tarda segundos, así que hazlo en un worker o precalcúlalo.
Cuándo el LOD no ayuda
Este es el punto que hace falta escuchar antes de meter LOD en todas partes: el LOD reduce triángulos, no draw calls. Y en la mayoría de las escenas web el cuello de botella no son los triángulos.
Un LOD sustituye un objeto de 50 000 triángulos por uno de 5 000. Sigue siendo un draw call. Si tu escena tiene dos mil objetos y va lenta porque son dos mil draw calls, ponerles LOD a todos no cambia nada: seguirán siendo dos mil draw calls, ahora de objetos más pobres. Peor: has multiplicado por tres la memoria de geometría y has añadido un cálculo de distancia por objeto y por frame.
El LOD gana claramente en tres situaciones:
Pocos objetos muy densos. Un modelo escaneado, un personaje detallado, un edificio con interiores. Ahí sí dominan los triángulos.
Muchas copias del mismo objeto a distancias muy variadas. Un bosque, una multitud. Y ahí, además, la combinación correcta suele ser LOD más instancing: un InstancedMesh por nivel de detalle, y los objetos se reparten entre ellos según su distancia. Eso reduce triángulos y draw calls a la vez.
Cuando el nivel más bajo elimina el objeto por completo. Un addLevel con una malla vacía a distancia grande es la forma más simple de distance culling, y ese sí ahorra el draw call entero.
El diagnóstico que decide si el LOD te sirve es exactamente el que verás en el nivel de medición: si renderer.info.render.triangles es alto y renderer.info.render.calls es bajo, el LOD ayuda. Si es al revés, el problema es otro.
Todo el mundo intenta arreglar el salto visible entre niveles con las herramientas equivocadas. Se sube la histéresis, se alejan los umbrales, se añaden más niveles intermedios, y el salto sigue viéndose. La razón es que el ojo humano no detecta el cambio de densidad de triángulos: detecta el cambio de silueta. Un modelo simplificado al 20 % que conserve exactamente su contorno pasa desapercibido a diez metros; uno simplificado al 60 % que redondee una esquina o corte un saliente canta desde cuarenta. Por eso los simplificadores buenos —meshoptimizer es el estándar de facto— exponen un parámetro para bloquear los vértices del borde y penalizan fuertemente los colapsos que alteran el contorno, aunque eso les obligue a gastar más triángulos de los estrictamente necesarios en esas zonas. Y por eso los simplificadores genéricos por coste de arista, como el SimplifyModifier de Three.js, producen popping desproporcionado para la reducción que consiguen: su métrica es la curvatura local, que no sabe nada de qué aristas forman silueta desde el punto de vista del observador. De ahí salen dos consecuencias prácticas. La primera: cuando evalúes un nivel de detalle, no lo mires de frente y de cerca; míralo desde la distancia a la que va a usarse y compáralo por su contorno, alternando los dos rápidamente. Si la silueta se mueve, el nivel está mal aunque el interior sea idéntico. La segunda: la técnica que de verdad elimina el popping cuando no hay más remedio no es más histéresis, es el cross-fade, renderizar los dos niveles unos frames con opacidad complementaria. Cuesta un draw call extra durante la transición y unos cuantos problemas de ordenación de transparencia, y es la razón por la que en la web casi siempre sale más barato aceptar un salto sutil y colocar los umbrales lo bastante lejos como para que ocurra fuera de la zona de atención.
- Construye un LOD de tres niveles con
SimplifyModifiery observa el salto sin histéresis. - Añade histéresis de 0,1 y colócate justo en el umbral para comprobar que el parpadeo desaparece.
- Añade un cuarto nivel vacío a distancia grande y verifica en
renderer.infoque el draw call desaparece. - Compara siluetas de los tres niveles desde la distancia a la que se usan, alternando rápido entre ellos.
- Monta un bosque de 3000 árboles con un
InstancedMeshpor nivel y reparte las instancias por distancia.