Lightmaps y el segundo juego de UVs que exigen
Por qué un lightmap no puede compartir las UVs del material, qué significa que un unwrap sea inyectivo, cómo se llama el atributo en Three.js, y el margen que evita las manchas.
Un lightmap es una textura en la que cada texel guarda la luz que llega a un punto concreto de la superficie. Esa frase esconde una exigencia que rompe el flujo de trabajo normal de las texturas: cada punto de la malla tiene que ocupar un sitio distinto en la textura. Las UVs de material, que se repiten y se solapan alegremente porque eso ahorra espacio y es lo correcto para un patrón de baldosas, son estructuralmente inservibles para esto. De ahí sale la parte más laboriosa de todo el horneado.
- Justificar por qué las UVs de material no sirven para un lightmap con un ejemplo concreto.
- Definir qué propiedad tiene que cumplir un unwrap para lightmap y cómo se verifica.
- Asignar el segundo juego de UVs en Three.js con el nombre de atributo correcto.
- Dimensionar la resolución y el margen de un lightmap a partir del tamaño real de la geometría.
Por qué no valen las UVs del material
Imagina una habitación de cuatro paredes texturizada con un papel pintado. Lo eficiente es que las cuatro paredes usen la misma región de la textura: el patrón se repite, la textura es pequeña y todo funciona. Con wrapS y wrapT en RepeatWrapping y un repeat de 4, una textura de 512 cubre una pared de ocho metros.
Ahora hornea un lightmap sobre esas mismas UVs. La pared norte recibe luz de la ventana y la sur está en penumbra, pero las dos escriben en los mismos texels. El horneado escribe una encima de otra y el resultado es basura: las cuatro paredes acaban con la iluminación de la última que se procesó.
El requisito es, en términos precisos, que el mapeo de la superficie a la textura sea inyectivo: dos puntos distintos de la malla nunca comparten un mismo punto UV. Se dice que el unwrap está libre de solapes. Y es una propiedad que las UVs de material no solo no cumplen, sino que están diseñadas para violar.
De ahí las tres diferencias sistemáticas entre los dos juegos de UVs:
UVs de material (uv) |
UVs de lightmap (uv1) |
|
|---|---|---|
| Solapes | deseables, ahorran textura | prohibidos |
Repetición fuera de [0,1] |
habitual | prohibida, todo dentro del cuadrado |
| Densidad de texels | variable, más donde hay detalle | uniforme en toda la superficie |
| Islas | pocas y grandes, siguiendo el diseño | muchas, cortadas por ángulo |
La tercera fila también importa y se olvida: en un lightmap, la densidad tiene que ser uniforme en unidades de mundo. Si una pared de diez metros ocupa el mismo espacio UV que una silla de medio metro, la pared tendrá veinte veces menos resolución de iluminación y se verá a bloques mientras la silla desperdicia texels.
Cómo se llama el atributo en Three.js
Este es el punto donde más gente se atasca, porque el nombre cambió y la documentación antigua sigue circulando.
El atributo de geometría se llama uv1. En versiones anteriores a r151 se llamaba uv2, y todavía hoy medio internet lo llama así. En r184 el nombre correcto es uv1, y el conjunto completo es uv, uv1, uv2, uv3, donde uv es el principal y uv1 es el que consumen lightMap y aoMap.
// Si el glTF trae TEXCOORD_1, GLTFLoader lo pone en uv1 automaticamente.
malla.geometry.attributes.uv1; // BufferAttribute o undefined
// Si tu geometria solo tiene un juego de UVs y quieres usarlas
// tambien para el lightmap (valido SOLO si no tienen solapes):
malla.geometry.setAttribute( 'uv1', malla.geometry.attributes.uv );
Esa última línea es el atajo que se usa constantemente para el aoMap de un objeto simple cuyo unwrap ya era limpio. No copia datos: comparte el mismo BufferAttribute, así que es gratis.
Y la aplicación del lightmap:
const lightmap = await new THREE.TextureLoader().loadAsync( '/bake/salon.webp' );
// Un lightmap es color de luz: va en espacio sRGB si se exporto asi.
lightmap.colorSpace = THREE.SRGBColorSpace;
// Los lightmaps NO se voltean: el horneador ya usa el origen abajo-izquierda
// igual que WebGL. Este es el ajuste que casi nadie recuerda.
lightmap.flipY = false;
salon.material.lightMap = lightmap;
salon.material.lightMapIntensity = 1.0;
salon.material.needsUpdate = true;
TextureLoader pone flipY = true por defecto porque las imágenes web tienen el origen arriba a la izquierda y WebGL abajo. Los lightmaps exportados desde Blender ya vienen con la convención de WebGL si el modelo se exportó a glTF, porque el exportador voltea las UVs. El síntoma de equivocarse es inconfundible: la iluminación aparece reflejada verticalmente, con la luz de la ventana en el suelo. Si tu lightmap viene dentro del propio glTF, GLTFLoader ya lo gestiona; si lo cargas aparte, comprueba este ajuste antes que ningún otro.
Resolución, densidad y el margen
Un lightmap se dimensiona en texels por unidad de mundo, no en píxeles totales. La pregunta correcta no es «¿2048 o 4096?» sino «¿cuántos centímetros mide un texel de luz?».
Los valores que funcionan en la práctica:
| Uso | Texels por metro | Ejemplo |
|---|---|---|
| Terreno o exterior grande | 1 a 4 | un metro por texel |
| Interior arquitectónico | 8 a 20 | 5 a 12 cm por texel |
| Objeto de producto en primer plano | 40 a 100 | 1 a 2.5 cm por texel |
Con 10 texels por metro, un salón de 6 por 5 metros con paredes de 2.7 —unos 90 metros cuadrados de superficie total contando suelo, techo y paredes— necesita 9000 texels cuadrados, es decir un mapa de unos 1024 por 1024 con el espacio bien aprovechado. Y ahí está la clave: el lightmap es sorprendentemente pequeño si la densidad es coherente, y monstruoso si no lo es.
El otro parámetro crítico es el margen entre islas del unwrap, llamado padding o island margin. Su necesidad viene del filtrado bilineal y de los mipmaps: cuando la GPU muestrea un texel del borde de una isla, interpola con sus vecinos, y si el vecino pertenece a otra isla o al fondo vacío, el color se contamina. El resultado son costuras oscuras o líneas de otro color en las aristas de la geometría.
La regla es que el margen tiene que ser de al menos dos texels a la resolución final, y crecer si usas mipmaps: cada nivel de mip duplica el radio de contaminación. Con cuatro niveles de mip, hacen falta unos 16 texels de margen. Por eso los horneadores serios hacen dilation: rellenan los píxeles vacíos alrededor de cada isla extendiendo el color del borde, lo que soluciona el problema sin gastar margen.
three/addons/utils/UVsDebug.js exporta una función UVsDebug( geometry, size ) que devuelve un canvas con el unwrap dibujado. Se pinta en el DOM y se mira: los solapes se ven al instante como zonas donde los triángulos se cruzan. Es medio minuto de trabajo y detecta el 90 % de los problemas de lightmap antes de hornear nada.
Un detalle sobre lightMapIntensity
La propiedad multiplica la contribución del lightmap y vale 1 por defecto. Es tentador usarla para «arreglar» un horneado que salió oscuro, y casi siempre es la respuesta equivocada.
Un lightmap correcto contiene irradiancia lineal. Si sale oscuro, las causas probables son que se exportó con curva de gamma aplicada dos veces, que se horneó el pase equivocado, o que la exposición de Blender no coincide con el toneMappingExposure de Three.js. Subir lightMapIntensity a 3 tapa el síntoma pero rompe la relación con el resto de la iluminación: los objetos dinámicos, que se iluminan con el entorno, quedarán ahora sistemáticamente más oscuros que el escenario. El uso legítimo de lightMapIntensity es el ajuste fino, entre 0.8 y 1.2.
Blender tiene Smart UV Project y Lightmap Pack, y los dos generan un unwrap sin solapes en un clic. Es tentador usarlos y seguir adelante. El problema no aparece en el horneado —el horneado sale bien— sino después, y es de dos tipos. El primero es la fragmentación: un unwrap automático con ángulo agresivo puede producir cientos de islas diminutas, y cada isla necesita su margen. Con 400 islas y 8 texels de margen por isla, el margen consume más superficie que las islas, y la densidad efectiva de tu mapa de 2048 acaba siendo la de uno de 900. La segunda, más insidiosa, es que cada isla es una costura, y una costura es una discontinuidad en la iluminación: por muy bien horneado que esté, los dos lados de una arista cortada se interpolan de manera distinta y aparece una línea tenue. En una pared plana eso es invisible; en una superficie curva con gradiente suave de luz, se ve como una grieta. El unwrap bueno para lightmap se hace con criterios opuestos a los del unwrap de material: cortar lo mínimo posible, cortar por aristas cóncavas donde la iluminación ya cambia bruscamente, y mantener las superficies continuas de una pieza aunque eso deje espacio muerto en el atlas. Una pared entera, incluidos sus cuatro lados, debería ser una única isla. Es trabajo manual, cuesta una tarde para un interior, y es la diferencia entre un horneado que se ve como iluminación y uno que se ve como una textura pegada con costuras. Si no tienes esa tarde, la alternativa honesta no es aceptar un unwrap automático malo: es usar solo aoMap, que tolera muchísimo peor unwrap porque su señal es de alta frecuencia y las costuras se camuflan.