Ambient occlusion horneado: qué es y qué no es
Por qué el aoMap solo atenúa la luz indirecta, qué mide realmente un mapa de oclusión, cuándo va en uv1 y cuándo no, y su relación con el canal rojo de los mapas ORM.
Qué mide, exactamente
Para cada punto de la superficie, se lanza un montón de rayos sobre el hemisferio orientado según la normal y se cuenta qué fracción escapa sin chocar con nada dentro de una distancia dada. Ese cociente es la oclusión ambiental: 1 significa cielo completamente visible, 0 significa completamente tapado.
Fíjate en lo que no aparece en esa definición: las luces. La oclusión ambiental es una propiedad puramente geométrica. Por eso un aoMap sobrevive a cualquier cambio de iluminación, sirve para objetos que se mueven —siempre que no se deformen— y se puede hornear una vez y reutilizar en todos los proyectos donde aparezca ese modelo.
El parámetro que hay que ajustar al hornear es la distancia máxima del rayo. Con distancia infinita, cualquier objeto en una habitación queda oscurecido por el techo y la oclusión pierde información local. Con una distancia del orden del tamaño de los detalles —unos centímetros para un objeto de mesa, medio metro para arquitectura— captura exactamente lo interesante: los rincones, las junturas, los huecos entre piezas.
Qué atenúa y qué no en Three.js
Este es el punto donde se rompe la expectativa de casi todo el mundo. En el shader de Three.js, el valor del aoMap solo multiplica la irradiancia indirecta: el ambiente y el entorno IBL. No toca la luz directa de ninguna luz analítica.
| Contribución | ¿La atenúa aoMap? |
|---|---|
AmbientLight |
sí |
HemisphereLight |
sí |
scene.environment difuso |
sí |
scene.environment especular |
sí, con atenuación específica del especular |
DirectionalLight, PointLight, SpotLight |
no |
lightMap |
no |
Las dos últimas filas explican el 90 % de los mensajes de «he puesto el aoMap y no se ve nada»: la escena está iluminada con una DirectionalLight y nada más, así que no hay término indirecto que atenuar. Sin scene.environment o sin luces de relleno, el aoMap es literalmente invisible. No es un bug, es la definición: la oclusión ambiental describe el bloqueo de luz ambiente, y si no hay luz ambiente no hay nada que bloquear.
Que no toque la luz directa también es correcto y deliberado: la sombra de la luz directa la calcula el shadow map, con la geometría real y la posición real de la luz. Aplicarle además la oclusión sería contar dos veces la misma oclusión y dejar los rincones demasiado negros.
Y que no toque el lightMap es igualmente correcto en teoría: un lightmap horneado con un trazador ya lleva la oclusión dentro, porque los rayos que rebotan ya se bloquearon en los rincones. Aplicar aoMap encima sería duplicar. Cuando uses los dos juntos, el aoMap debe estar ahí solo para el detalle de alta frecuencia que la resolución del lightmap no captura.
const material = new THREE.MeshStandardMaterial( { map: colorTex, aoMap: aoTex, aoMapIntensity: 1.0, // 0 desactiva, 1 es el valor completo} );// SIN esto, el aoMap no hace absolutamente nada visible:scene.environment = pmrem.fromScene( new RoomEnvironment(), 0.04 ).texture;uv o uv1: cuándo cada uno
aoMap lee de uv1, igual que lightMap. Pero la exigencia es mucho más laxa que la del lightmap, y esa diferencia cambia el flujo de trabajo.
Un lightmap necesita un unwrap sin solapes porque cada punto tiene un valor de luz distinto. Un aoMap de un objeto simétrico puede tolerar perfectamente que las dos mitades compartan UVs, porque su oclusión es la misma. Y un aoMap de un objeto con UVs de material limpias y sin repetición puede usar directamente esas UVs:
// Caso muy comun: el modelo tiene un unwrap unico y sin solapes,// pensado para texturas, y sirve tal cual para el AO.geometry.setAttribute( 'uv1', geometry.attributes.uv );La comprobación es simple: si el material repite la textura —repeat distinto de 1— o si dos partes distintas del modelo comparten la misma región UV con oclusión distinta, hace falta un segundo unwrap. En cualquier otro caso, compartir es correcto y ahorra el trabajo entero.
Los mapas ORM y el canal rojo
En el ecosistema glTF, la oclusión rara vez viene en un fichero aparte. La convención es empaquetar tres señales monocromas en los tres canales de una sola imagen:
| Canal | Contenido |
|---|---|
| Rojo | Occlusion |
| Verde | Roughness |
| Azul | Metalness |
De ahí el nombre ORM. Es una optimización obvia: tres texturas de un canal ocupan lo mismo que una de tres canales, pero se descargan en una sola petición y ocupan una sola unidad de textura en el shader.
Three.js lo soporta directamente asignando la misma textura a las tres propiedades:
const orm = await new THREE.TextureLoader().loadAsync( '/modelo/orm.webp' );// ORM es dato, no color: NO lleva sRGB.orm.colorSpace = THREE.NoColorSpace;material.aoMap = orm; // usa el canal Rmaterial.roughnessMap = orm; // usa el canal Gmaterial.metalnessMap = orm; // usa el canal BCada uno de los tres muestreos lee su canal y descarta el resto. GLTFLoader hace exactamente esto al cargar un modelo con occlusionTexture y metallicRoughnessTexture apuntando al mismo fichero.
Ojo con el espacio de color: un ORM contiene datos, no color percibido. Ponerle SRGBColorSpace aplicaría una transferencia de gamma a valores de rugosidad y metalidad, con lo que ambos saldrían desplazados. El valor correcto es NoColorSpace, que es además el que asigna GLTFLoader.