Saltar al contenido

aoMap y lightMap: por qué exigen un segundo juego de UVs

Qué término de la iluminación multiplica cada uno, qué significa que un desenvolvido no pueda solaparse, y cómo se elige el canal de UV en Three.js.

Qué multiplica exactamente aoMap

La oclusión ambiental estima qué fracción del hemisferio le queda visible a cada punto de la superficie. Un rincón entre dos paredes ve poco cielo, así que recibe menos luz ambiental que una superficie abierta. Es una aproximación barata de un efecto de iluminación global.

En Three.js se aplica así:

GLSL
float ambientOcclusion = ( texture2D( aoMap, vAoMapUv ).r - 1.0 ) * aoMapIntensity + 1.0;reflectedLight.indirectDiffuse *= ambientOcclusion;#if defined( USE_ENVMAP ) && defined( STANDARD )  float dotNV = saturate( dot( geometryNormal, geometryViewDir ) );  reflectedLight.indirectSpecular *= computeSpecularOcclusion( dotNV, ambientOcclusion, material.roughness );#endif

Tres cosas importantes ahí.

La primera es la fórmula de la intensidad. aoMapIntensity interpola entre uno, sin oclusión, y el valor del mapa. Con intensidad cero el mapa no hace nada; con uno se aplica entero. No es un multiplicador simple, es una mezcla.

La segunda es que la oclusión afecta solo a los términos indirectos. La luz directa de una lámpara no se atenúa, y es correcto: la oclusión ambiental describe el acceso a la luz ambiente, no la sombra proyectada de una fuente concreta. Si oscureciera también la directa, un rincón iluminado de frente por un foco saldría absurdamente oscuro.

La tercera es que el especular indirecto no se multiplica por el mismo valor sino por una oclusión especular calculada aparte, que depende del ángulo de vista y de la rugosidad. La razón física es que una superficie pulida refleja en una dirección concreta y puede perfectamente reflejar algo aunque esté en un rincón, así que ocluirla con el mismo factor que el difuso la oscurecería de más.

Con clearcoat o sheen activos, sus términos indirectos también se multiplican por la oclusión.

Por qué no vale el primer desenvolvido

El desenvolvido normal de un modelo está optimizado para aprovechar la textura. Eso significa dos cosas que aquí son venenosas: las islas se solapan cuando dos partes del modelo pueden compartir la misma región de textura, y las UV salen del rango de cero a uno cuando la textura se repite.

Para un mapa de ladrillo, ambas cosas son ventajas: las cuatro patas idénticas de una mesa pueden compartir téxeles y ahorrar tres cuartas partes de la memoria.

Para un mapa de oclusión, ambas son fatales. La oclusión de la pata delantera izquierda es distinta de la de la trasera derecha, porque están en sitios distintos y ven cosas distintas. Si comparten téxeles, solo puedes guardar un valor y las cuatro patas se verán con la oclusión de una sola. Y con UV fuera del rango, la oclusión se repetiría en mosaico, lo cual no tiene ningún sentido.

De ahí el requisito, que la documentación de Three.js repite en cada material: aoMap y lightMap requieren un segundo juego de UVs. Ese segundo desenvolvido tiene que ser inyectivo, es decir, cada punto del modelo en su propio téxel, sin solapamientos y dentro del rango. En las herramientas de modelado suele llamarse desenvolvido de lightmap y se genera automáticamente.

uv1 y el canal de la textura

Three.js soporta hasta cuatro juegos de coordenadas en los atributos uv, uv1, uv2 y uv3. Cada textura elige el suyo con la propiedad channel, que vale 0 por defecto y se corresponde con uv.

JS
import * as THREE from 'three';const geometria = new THREE.BoxGeometry(1, 1, 1);// El segundo juego, aqui simplemente una copia del primero.// En produccion vendria del desenvolvido de lightmap del modelo.geometria.setAttribute('uv1', geometria.attributes.uv.clone());const oclusion = new THREE.TextureLoader().load('/texturas/caja_ao.png');oclusion.channel = 1;         // usa uv1const material = new THREE.MeshStandardMaterial({  map: color,  aoMap: oclusion,  aoMapIntensity: 1.0});

La propiedad channel es relativamente reciente en la historia de Three.js. Durante años el segundo juego estaba cableado y el atributo se llamaba uv2, así que hay mucho código y muchos tutoriales que hacen geometry.setAttribute('uv2', ...). Eso ya no funciona como antes: el atributo uv1 es el segundo juego, y la selección se hace con channel.

Con modelos glTF no hay que hacer nada de esto: el formato declara qué juego usa cada textura y GLTFLoader configura channel correctamente. El trabajo manual solo aparece con geometría propia o con texturas que asignas tú a un modelo cargado.

lightMap y el horneado

lightMap es el paso siguiente: en lugar de guardar cuánta luz ambiental se ocluye, guarda cuánta luz llega, con su color, calculada por adelantado con un algoritmo que sí puede permitirse rebotes múltiples. Se multiplica por la contribución difusa y se escala con lightMapIntensity.

Es la técnica que hace posibles los interiores creíbles en la web: la iluminación global que un motor en tiempo real no puede calcular se precocina en un render offline y viaja como una textura. Comparte con aoMap el requisito del segundo desenvolvido, por la misma razón exacta, y añade uno propio: la escena tiene que ser estática, porque la luz horneada no se entera de que un objeto se ha movido.

Conviene no confundir el aoMap horneado con la oclusión ambiental calculada en pantalla en post-proceso. La horneada conoce la geometría real del objeto entero, incluidos detalles que no están en la malla de baja densidad, y no cuesta nada en tiempo de ejecución más allá de una lectura de textura. La de pantalla solo conoce lo que está en el buffer de profundidad, no ve lo que queda fuera de cámara, y cuesta un pase completo. Se usan juntas, no como alternativas.