wandres.dev
TEXTURAS II · Los mapas de un material

normalMap y el espacio tangente

Qué guardan esos píxeles lavanda, por qué la normal se almacena relativa a la superficie, y el error de convención que hace que los relieves se vean hundidos.

⏱ 19 min

El mapa de normales es la textura que más detalle aporta por byte y la que más fallos silenciosos produce. Guarda un vector por téxel, ese vector está expresado en un sistema de coordenadas que cambia en cada punto de la superficie, y hay dos convenciones incompatibles sobre el signo de uno de sus ejes. Cuando algo se ve raro y no sabes por qué, el mapa de normales es el primer sospechoso.

🎯 Al terminar esta lección sabrás
  • Interpretar los valores RGB de un mapa de normales tangencial.
  • Justificar por qué el espacio tangente es la elección correcta frente al espacio de objeto.
  • Ajustar normalScale y corregir la convención de handedness.
  • Reconocer los límites de lo que un mapa de normales puede simular.

Qué hay en esos píxeles

Un mapa de normales guarda un vector unitario por téxel, con sus tres componentes en los canales rojo, verde y azul. Como los canales van de cero a uno y las componentes de menos uno a uno, hay un remapeo: el valor 0.5 corresponde a componente cero.

De ahí sale el color característico. Una zona plana tiene normal (0, 0, 1), que codificada es (0.5, 0.5, 1.0), ese lavanda azulado que reconoces al instante. Las zonas que se desvían hacia la derecha tienen más rojo, las que se desvían hacia arriba más verde, y el azul se mantiene alto en todas partes porque la componente perpendicular domina siempre.

Ese vector no está en coordenadas del mundo ni del objeto. Está en espacio tangente: un sistema local a cada punto de la superficie, formado por la tangente, que sigue la dirección de crecimiento de u; la bitangente, que sigue la de v; y la normal geométrica. Los tres se llaman colectivamente la base TBN.

Por qué espacio tangente y no espacio de objeto

La alternativa, el espacio de objeto, guarda la normal en coordenadas del modelo y produce mapas de colores vivos y variados. Es más simple de aplicar: no hace falta ninguna base local, se usa el vector directamente.

Y sin embargo, casi nadie lo usa. Tres razones:

No se puede repetir. Un mapa en espacio de objeto es específico de una orientación concreta de la superficie. Un mapa de ladrillo en espacio tangente sirve para una pared, un suelo y un techo; en espacio de objeto haría falta uno por orientación.

No sobrevive a la deformación. Si el modelo se anima, se dobla o se deforma con morph targets, sus normales de objeto dejan de corresponder. Las tangenciales se recalculan solas porque la base TBN sigue a la superficie.

No se puede compartir. Un mismo mapa tangencial vale para todos los modelos que usen esa superficie. Uno de objeto es de un modelo y solo de ese modelo.

Three.js soporta los dos con normalMapType, que acepta TangentSpaceNormalMap, el defecto, y ObjectSpaceNormalMap. El segundo tiene un nicho real: modelos escaneados de alta densidad que no se animan, donde evita los artefactos de las tangentes en desenvolvidos complicados.

normalScale y la convención del verde

normalScale es un Vector2 con valor por defecto (1, 1). Multiplica las componentes X e Y de la normal leída antes de renormalizar, lo que en la práctica controla la intensidad del relieve. Valores menores que uno lo suavizan; mayores que uno lo exageran, con el riesgo de producir normales muy inclinadas que generan brillos raros.

import * as THREE from 'three';

const normales = new THREE.TextureLoader().load('/texturas/piedra_normal.png');
// Es un dato, NO se marca como sRGB.

const material = new THREE.MeshStandardMaterial({
  map: colorPiedra,
  normalMap: normales,
  normalScale: new THREE.Vector2(0.7, 0.7),
  roughness: 0.85
});

Fíjate en que es un Vector2 y no un número. Eso permite intensidades distintas en cada eje, cosa que rara vez sirve, y permite algo que sí sirve mucho: negar una de las dos componentes.

El bache que parece un agujero es el canal verde al revés

Existen dos convenciones para el eje vertical de un mapa de normales. La convención OpenGL, también llamada Y positivo o dextrógira, tiene el verde creciendo hacia arriba. La convención DirectX, Y negativo o levógira, lo tiene creciendo hacia abajo. Los dos mapas se ven casi idénticos si no los comparas lado a lado.

Three.js espera la convención OpenGL. Si le das un mapa de DirectX, no hay ningún error: la iluminación se calcula con la componente vertical invertida, y el resultado es que los relieves salientes se ven hundidos y los huecos se ven salientes. El cerebro casi nunca lo detecta directamente; lo que percibes es que “algo no encaja” con la dirección de la luz, y que el material parece plano o raro sin saber por qué. La forma fiable de detectarlo es poner una luz claramente lateral y comprobar que la sombra propia del relieve cae en el lado contrario a la luz. Si cae en el mismo lado, tienes la convención cambiada.

La corrección es una línea, y la propia documentación de MeshStandardMaterial la indica: negar la componente y de normalScale.

material.normalScale.set( 1, -1 );   // mapa creado en convencion DirectX

Hay un segundo caso donde el mismo signo importa y no es culpa tuya: el renderer niega automáticamente normalScale cuando el material se dibuja con side igual a BackSide, para que la cara trasera se ilumine coherentemente. Y hace lo equivalente con bumpScale. Con DoubleSide eso implica que las dos caras usan signos distintos, que es lo correcto y a la vez es la razón por la que una tela con doble cara y mapa de normales puede verse bien por un lado y rara por el otro si el mapa se creó pensando en una sola orientación.

Bump frente a normal

bumpMap guarda una altura en escala de grises, no un vector. El shader calcula las derivadas de esa altura en pantalla y con ellas perturba la normal. Cuesta menos memoria, un canal en lugar de tres, y es más fácil de pintar a mano.

A cambio pierde precisión y direccionalidad: la derivada calculada en pantalla depende de la resolución y del ángulo de vista, así que el relieve puede parpadear al mover la cámara. Y no puede expresar una normal arbitraria, solo la que se deduce de una superficie de altura, lo que descarta detalles con voladizos.

Hay un dato operativo que resuelve la mitad de los problemas con estos dos mapas: si hay normalMap, el bumpMap se ignora por completo. Lo dice la documentación de todos los materiales que tienen los dos. Si has asignado los dos y estás tocando bumpScale sin ver ningún cambio, ese es el motivo.

Lo que un mapa de normales no puede hacer

Es importante saber dónde está el techo, porque explica cuándo hay que pasar a otra técnica.

No cambia la silueta. El contorno del objeto sigue siendo el de la geometría. Un ladrillo con relieve de dos centímetros visto de canto sigue teniendo el borde perfectamente recto, y eso delata el truco inmediatamente.

No produce paralaje. El relieve no se desplaza respecto al fondo al mover la cámara, porque no hay profundidad real. A distancia media no se nota; en primer plano y con movimiento, sí.

No se auto-ocluye ni se auto-sombrea. Un saliente no proyecta sombra sobre el hueco de al lado. Por eso los mapas de normales se acompañan de un mapa de oclusión ambiental horneado, que aporta justo esa información que falta.

Las tres limitaciones tienen respuesta y ninguna es gratis: mapeo de paralaje para la segunda, con un bucle de búsqueda en el fragment shader; y displacementMap con geometría subdividida para las tres, que es geometría real y cuesta como tal.