Saltar al contenido

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.

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.

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

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 sí parcial medio, en fragment
displacementMap sí sí sí 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.