Saltar al contenido

Desplazamiento por textura

Leer un mapa de alturas desde el vertex shader: la configuración exacta de la textura, el error de espacio de color que arruina la escala, y el bandeado de ocho bits.

El muestreo en el vertex shader

La lectura de texturas desde la etapa de vértices está garantizada en WebGL 2: la especificación exige al menos dieciséis unidades de textura accesibles desde el vertex shader, frente a las cero que WebGL 1 podía ofrecer. No hay que comprobar nada.

GLSL
uniform sampler2D uAlturas;uniform float uAmplitud;varying vec2 vUv;varying float vAltura;void main() {  vUv = uv;  float h = texture2D( uAlturas, uv ).r;  vAltura = h;  vec3 p = position;  p.z += h * uAmplitud;  gl_Position = projectionMatrix * modelViewMatrix * vec4( p, 1.0 );}

Lo que hay que saber es que en el vertex shader no hay derivadas, porque no existen fragmentos vecinos. Eso significa que el nivel de mipmap no se puede deducir y la lectura usa el nivel base. Si la textura tiene mipmaps generados, ocupan memoria que nunca se usa; y si la textura no está completa —por ejemplo con un minFilter que exige mipmaps que no se generaron— la lectura devuelve negro.

La configuración de la textura

Cinco líneas, y todas importan.

JS
const alturas = await new THREE.TextureLoader().loadAsync( '/texturas/alturas.png' );alturas.colorSpace = THREE.NoColorSpace;          // es un valor, no un coloralturas.generateMipmaps = false;                  // nunca se van a usaralturas.minFilter = THREE.LinearFilter;           // sin mipmaps, minFilter no puede pedirlosalturas.magFilter = THREE.LinearFilter;alturas.wrapS = THREE.ClampToEdgeWrapping;        // sin repeticion: el borde no debe envolveralturas.wrapT = THREE.ClampToEdgeWrapping;

ClampToEdgeWrapping merece un comentario. Con RepeatWrapping, un vértice en uv exactamente 1.0 muestrea el borde opuesto por el redondeo, y el terreno se cierra sobre sí mismo con un escalón. Con ClampToEdgeWrapping el borde se extiende y el escalón desaparece.

El error del espacio de color

Éste es el error que más tiempo cuesta descubrir porque no produce ningún síntoma que apunte a su causa: simplemente el terreno sale más plano de lo que debería y uno se pasa media hora subiendo uAmplitud.

Desde que la gestión de color está activa por defecto, una textura marcada como SRGBColorSpace se sube a la GPU con un formato interno sRGB, y el hardware aplica la función de transferencia inversa al muestrear. Un téxel con valor 128 sobre 255 no llega al shader como 0.502: llega como 0.216.

Texto
codificado 0.25  ->  lineal 0.0508codificado 0.50  ->  lineal 0.2140codificado 0.75  ->  lineal 0.5225codificado 1.00  ->  lineal 1.0000

Es decir, se aplasta la mitad baja del rango. Un mapa de alturas decodificado así produce un terreno con los valles hundidos y las cimas más o menos correctas, y la relación entre unos y otras es errónea de una forma que no se puede compensar con un factor de escala.

La regla: cualquier textura cuyos valores signifiquen un número y no un color va en NoColorSpace. Alturas, rugosidad, metalicidad, oclusión, máscaras, mapas de normales, ruido precalculado, datos de simulación. Solo el mapa de color base y el emisivo son SRGBColorSpace.

En el caso concreto de un ShaderMaterial, TextureLoader deja colorSpace en NoColorSpace por defecto, así que si no lo tocas estás bien. El problema aparece al copiar código de un MeshStandardMaterial, donde alguien puso SRGBColorSpace porque ahí sí hacía falta para el mapa de color.

Ocho bits no bastan

Un PNG de ocho bits por canal tiene 256 niveles. Con una amplitud de veinte unidades, cada nivel son casi ocho centímetros, y esos escalones se ven: el terreno queda terraceado como un campo de arroz.

Hay cuatro salidas y conviene conocer las cuatro.

Suavizar en el muestreo. El filtrado lineal ya interpola entre téxeles, así que los escalones solo aparecen cuando la malla tiene más resolución que el mapa. Si el mapa es más grande que la malla, no hay problema.

Usar un formato de más rango. Y aquí hay una trampa: un PNG de dieciséis bits por canal cargado con TextureLoader no conserva los dieciséis bits, porque el camino pasa por un HTMLImageElement y el navegador entrega la imagen ya decodificada a ocho bits por canal. Para obtener precisión real hay que usar un formato que Three.js parsee por su cuenta hasta un array tipado: EXRLoader de los addons, que devuelve HalfFloatType o FloatType, o un .hdr con HDRLoader, que es el nombre vigente desde r180 —RGBELoader sobrevive solo como envoltorio obsoleto que avisa por consola—. Con EXR de media precisión tienes once bits de mantisa, más que suficiente para cualquier terreno.

Repartir la altura en dos canales. Un truco clásico: el canal rojo lleva la parte gruesa y el verde la fina, y se recomponen con precisión efectiva de dieciséis bits sobre un PNG normal de ocho.

GLSL
// R es la parte alta, G la bajafloat leerAltura16( sampler2D mapa, vec2 uv ) {  vec2 c = texture2D( mapa, uv ).rg;  return c.r + c.g / 255.0;}

Generar el mapa en coma flotante. Si el mapa lo produces tú, una DataTexture de tipo FloatType o HalfFloatType no tiene ninguna cuantización.

JS
function crearMapaDeAlturas( lado, funcion ) {  const datos = new Float32Array( lado * lado );  for ( let y = 0; y < lado; y ++ ) {    for ( let x = 0; x < lado; x ++ ) {      datos[ y * lado + x ] = funcion( x / lado, y / lado );    }  }  const textura = new THREE.DataTexture(    datos, lado, lado, THREE.RedFormat, THREE.FloatType  );  textura.minFilter = THREE.LinearFilter;  textura.magFilter = THREE.LinearFilter;  textura.needsUpdate = true;  return textura;}

RedFormat con FloatType ocupa cuatro bytes por téxel en lugar de dieciséis, que para un mapa de un solo canal es la elección correcta.

Resolución de malla y resolución de mapa

Un mapa de 1024 por 1024 contiene un millón de valores. Una malla de 128 por 128 tiene dieciséis mil vértices, así que descarta el 98 % de la información. Y al revés: una malla de 2048 por 2048 sobre un mapa de 256 por 256 interpola linealmente entre téxeles, con lo que produce facetas planas unidas por aristas, que es peor que una malla más gruesa.

La correspondencia ideal es un vértice por téxel, es decir una malla de n segmentos para un mapa de n + 1 téxeles de lado. Cuando no se puede —porque un millón de vértices es demasiado— la respuesta correcta no es interpolar más, sino reducir el mapa antes, para que el filtrado ocurra sobre datos y no sobre una malla.

Y hay una consecuencia sobre las UV que se olvida siempre: en un PlaneGeometry de n segmentos, las UV van de cero a uno con los vértices en los extremos, mientras que los téxeles se muestrean en sus centros. La correspondencia exacta requiere un desplazamiento de medio téxel:

GLSL
vec2 uvCorregida = ( uv * float( uTamanoMapa - 1 ) + 0.5 ) / float( uTamanoMapa );

Para un mapa suave la diferencia es invisible; para uno con detalle fino, medio téxel es un desplazamiento real del terreno.