wandres.dev
TEXTURAS II · Los mapas de un material

displacementMap: geometría de verdad y su precio

El único mapa que mueve vértices, por qué necesita una malla subdividida, y cómo se compara con normal, bump y paralaje.

⏱ 17 min

De todos los mapas de un material, displacementMap es el único que se lee en el vertex shader y el único que cambia la silueta del objeto. Eso lo convierte en la solución definitiva a las tres limitaciones del mapa de normales, y a la vez en el más caro de todos por un motivo que no está en el shader sino en la malla: solo puede mover vértices que existan.

🎯 Al terminar esta lección sabrás
  • Explicar en qué etapa del pipeline actúa displacementMap y qué implica.
  • Calcular cuántos vértices necesita un desplazamiento para verse bien.
  • Usar displacementScale y displacementBias con sentido geométrico.
  • Elegir entre normal, paralaje y desplazamiento según el caso.

Un mapa que se lee en el vertex shader

Todos los demás mapas actúan en el fragment shader, sobre el color o sobre la iluminación. displacementMap actúa antes: en el vertex shader, desplazando cada vértice a lo largo de su normal una distancia proporcional al valor leído de la textura.

Que sea geometría real tiene consecuencias que ningún truco de iluminación puede imitar:

  • Cambia la silueta. El contorno del objeto se ondula de verdad.
  • Proyecta sombra. Los salientes tapan la luz de los huecos en el mapa de sombras.
  • Ocluye a otros objetos. Una piedra apoyada en el suelo desplazado se ve correctamente.
  • Tiene paralaje real, porque la posición cambia.

La contrapartida sale de la misma frase: se aplica por vértice. Un plano de dos triángulos tiene cuatro vértices, así que un mapa de desplazamiento de 2048 por 2048 aplicado a ese plano mueve cuatro puntos y el resto se interpola linealmente. El detalle de la textura no aparece por ninguna parte.

Cuántos vértices hacen falta

La regla es directa: para que se aprecie una característica del mapa de desplazamiento, tiene que haber al menos un vértice por característica, y en la práctica varios.

Para un plano de diez por diez unidades con un relieve cuyo detalle más fino mide una décima de unidad, hacen falta del orden de cien divisiones por lado. Eso son 101 por 101 vértices, unos diez mil, y veinte mil triángulos, para un solo plano.

import * as THREE from 'three';

const altura = new THREE.TextureLoader().load('/texturas/terreno_altura.png');
// Es un dato: sin espacio de color.

// 256 divisiones por lado: 66.049 vertices, 131.072 triangulos.
const geometria = new THREE.PlaneGeometry(20, 20, 256, 256);

const material = new THREE.MeshStandardMaterial({
  map: colorTerreno,
  normalMap: normalTerreno,      // el detalle fino, que la malla no puede dar
  displacementMap: altura,
  displacementScale: 2.5,        // altura maxima en unidades del mundo
  displacementBias: -1.0,        // desplaza el cero de referencia
  roughness: 0.95
});

const terreno = new THREE.Mesh(geometria, material);
terreno.rotation.x = -Math.PI / 2;

displacementScale es la distancia en unidades del mundo que corresponde a un valor de textura de uno. displacementBias se suma al resultado ya escalado, lo que permite centrar el desplazamiento: con scale a 2.5 y bias a -1.25, un valor de textura de 0.5 deja el vértice en su posición original y la superficie se desplaza igual hacia arriba y hacia abajo.

Fíjate también en que el ejemplo lleva normalMap además del desplazamiento. Es la combinación estándar y no una redundancia: la malla aporta las formas grandes y la silueta, el mapa de normales aporta el detalle por debajo de la resolución de la malla. Usar solo desplazamiento obligaría a una densidad de malla ridícula.

El coste, medido

El coste tiene tres componentes y conviene separarlos.

Memoria de vértices. Una malla de 256 por 256 son unos 66.000 vértices. Con posición, normal, UV y tangente son unos 48 bytes por vértice, es decir, más de tres megabytes de buffer solo para ese plano.

Trabajo de vertex shader. Cada uno de esos vértices ejecuta el shader y hace una lectura de textura. La lectura de textura en el vertex shader, el llamado vertex texture fetch, es una operación con más latencia que la equivalente en el fragment shader, porque no tiene mipmaps que elegir ni el mismo acceso a caché.

Recálculo de normales. Este es el que se olvida siempre. Al desplazar los vértices, las normales que trae la geometría dejan de corresponder a la nueva superficie. Three.js no las recalcula: la iluminación se sigue calculando con las normales del plano original, con lo que el terreno desplazado se ve iluminado como si fuera plano. La solución práctica es la del ejemplo: aportar un normalMap coherente con el mapa de altura, generado a partir de él en la misma herramienta. La solución rigurosa es recalcular la normal en el vertex shader tomando muestras vecinas del mapa de altura, lo que multiplica por tres las lecturas de textura.

El desplazamiento parte la malla, y no hay forma de coserla desde el material

Un mapa de desplazamiento mueve cada vértice a lo largo de su normal, y en una costura de desenvolvido hay dos vértices en la misma posición con UV distintas. Si esas dos UV caen en téxeles con valores de altura distintos, los dos vértices se van a sitios distintos, y la malla se abre. Aparece una grieta por la que se ve el interior del objeto.

Esto ocurre siempre que la textura no sea perfectamente continua a través de la costura, y en un desenvolvido con islas separadas eso es la norma, no la excepción. En un plano no pasa porque no hay costuras; en cualquier modelo desenvuelto de verdad, pasa.

Peor todavía: si los dos vértices tienen además normales distintas, porque la costura también es una arista dura, se separan aunque el valor de altura sea idéntico. Esa es la razón por la que un cubo con displacementMap se desmonta en seis planos flotantes.

No hay ninguna propiedad del material que arregle esto, porque el problema está en los datos. Las tres soluciones reales son: usar desplazamiento solo en mallas sin costuras, como planos y esferas generadas por código; hacer el desenvolvido con las costuras en zonas donde el mapa de altura sea constante; o rellenar el mapa con dilatación suficiente para que los dos lados de cada costura lean exactamente el mismo valor. La tercera es la que se hace en producción, y es la misma dilatación que ya necesitabas para los mipmaps.

Las cuatro técnicas, ordenadas

Puestas en una tabla, la decisión se vuelve mecánica:

técnica silueta paralaje auto-sombra coste
bumpMap no no no mínimo
normalMap no no no bajo
paralaje no parcial medio, en fragment
displacementMap alto, en geometría

bumpMap y normalMap resuelven el mismo problema y el segundo lo hace mejor; no hay razón para elegir el primero salvo que solo tengas un mapa de altura y no puedas convertirlo.

El mapeo de paralaje no viene de serie en Three.js y requiere un shader propio o una extensión del material estándar. Es un bucle de búsqueda en el fragment shader que desplaza la coordenada UV según el ángulo de vista, y da un resultado convincente para relieves de poca profundidad, como adoquines o ladrillos, a un coste que solo se paga en los píxeles que ocupa el objeto.

displacementMap es la única opción cuando el relieve tiene que interactuar con la escena: terreno sobre el que se anda, olas sobre las que flota algo, deformaciones que hay que poder tocar con un rayo. Fuera de eso, casi siempre hay una alternativa más barata que se ve igual de bien.

⚔️ Reto práctico

Toma un PlaneGeometry con un mapa de altura y genera versiones con 8, 32, 128 y 512 divisiones por lado. Para cada una, mide el tiempo de fotograma y anota a partir de qué densidad dejas de percibir mejora visual. Después añade el normalMap correspondiente al mapa de altura sobre la versión de 32 divisiones y compárala con la de 512 sin mapa de normales: casi con seguridad la de 32 con normales se ve mejor y cuesta una fracción.