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.
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 unidadeslod.addLevel( mallaMedia, 25 ); // de 25 a 80lod.addLevel( mallaBaja, 80 ); // de 80 en adelantelod.addLevel( impostor, 200 ); // muy lejos: un spritelod.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.
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.