wandres.dev
BAKING Y LIGHTMAPS · Precalcular la luz

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.

⏱ 16 min

La oclusión ambiental es el hermano pequeño del lightmap y el que más rendimiento da por unidad de esfuerzo. No guarda luz: guarda cuánto cielo ve cada punto de la superficie, un número entre 0 y 1 que no depende de dónde estén las luces. Eso lo hace reutilizable, robusto frente a cambios de iluminación y tolerante con unwraps mediocres. Y también lo hace fácil de usar mal, porque casi todo el mundo espera que oscurezca cosas que el aoMap de Three.js no toca ni por asomo.

🎯 Al terminar esta lección sabrás
  • Definir qué magnitud mide un mapa de oclusión ambiental y por qué es independiente de las luces.
  • Explicar qué términos del shading atenúa aoMap en Three.js y cuáles deja intactos.
  • Decidir si un aoMap concreto necesita uv1 o puede compartir uv.
  • Combinar oclusión horneada con oclusión en pantalla sin duplicar el efecto.

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
HemisphereLight
scene.environment difuso
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;
⚠️
aoMapIntensity por encima de 1 es un parche

La propiedad se puede subir por encima de 1 y la gente lo hace cuando el efecto se ve débil. Casi siempre el problema real es otro: falta entorno, o hay una AmbientLight rellenando lo que el AO quita, o la distancia de rayo del horneado era demasiado corta. Subir la intensidad amplifica también el ruido del horneado y produce halos negros en los bordes.

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 R
material.roughnessMap = orm;   // usa el canal G
material.metalnessMap = orm;   // usa el canal B

Cada 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.

La oclusión en pantalla y la horneada capturan escalas distintas, y usar las dos es lo correcto

Existe una tentación razonable de sustituir el aoMap por un pase de oclusión en pantalla —GTAOPass o SSAOPass en los addons— y ahorrarse todo el horneado. Funciona a medias, y entender por qué revela algo sobre las dos técnicas. La oclusión en pantalla trabaja sobre el buffer de profundidad de la vista actual: solo conoce lo que la cámara ve. Eso le da dos propiedades: captura la oclusión entre objetos distintos, incluidos objetos que se mueven, cosa que ningún horneado puede hacer; y no ve nada que esté fuera de pantalla o tapado, lo que produce el artefacto clásico de que la oclusión aparece y desaparece al mover la cámara, y de que un objeto grande fuera de encuadre deja de oscurecer lo que hay debajo. Su radio efectivo, además, está limitado por el coste: radios grandes exigen muchas muestras y se vuelven caros y ruidosos, así que en la práctica el SSAO captura escalas de centímetros a decímetros. El aoMap horneado es lo contrario: conoce la geometría completa, incluida la que está fuera de pantalla, y puede tener el radio que quieras porque se calculó offline; pero está congelado en el objeto y no sabe nada de sus vecinos. Las dos técnicas cubren, por tanto, escalas y contextos complementarios, y el montaje correcto en una escena que se lo pueda permitir es usar las dos a la vez: aoMap con un radio pequeño para las junturas, los tornillos y los pliegues del propio objeto, y GTAOPass con radio medio para el contacto entre objetos y con el suelo. El error que hay que evitar es hornear el aoMap con un radio enorme y además activar SSAO con radio grande: ahí sí estás contando dos veces la misma oclusión y los rincones se van a negro.