wandres.dev
TSL III · Node materials

positionNode y la geometría

Desplazar vértices desde un nodo, recalcular la normal para que la luz acompañe, y los slots de vértice que no son la posición.

⏱ 18 min

Cambiar el color de una superficie es fácil porque el fragmento no tiene memoria: sustituyes un valor y ya está. Cambiar su forma es distinto, porque la geometría desplazada arrastra una consecuencia que casi todo el mundo olvida en el primer intento: la normal sigue apuntando adonde apuntaba, y sin normal correcta no hay iluminación correcta.

🎯 Al terminar esta lección sabrás
  • Desplazar vértices con positionNode en espacio de objeto.
  • Recalcular la normal tras un desplazamiento y explicar el método.
  • Usar vertexNode y saber por qué casi nunca deberías.
  • Diferenciar los accesores de posición según su espacio.

El desplazamiento básico

import { positionLocal, normalLocal, sin, time, uniform } from 'three/tsl';

const uAmplitud = uniform( 0.15 );

const onda = sin( positionLocal.x.mul( 5 ).add( time.mul( 2 ) ) );

material.positionNode = positionLocal.add( normalLocal.mul( onda ).mul( uAmplitud ) );

positionNode sustituye la posición del vértice en espacio de objeto, antes de que se aplique la matriz de modelo-vista y la proyección. Es el equivalente exacto de modificar transformed justo después del chunk begin_vertex del sistema clásico, con la diferencia de que aquí no hay ancla que buscar.

Trabajar en espacio de objeto tiene una consecuencia que hay que tener presente: el desplazamiento se transforma con el objeto. Si rotas la malla, la onda rota con ella; si la escalas, la amplitud escala. Casi siempre es lo que quieres. Cuando no lo es —un efecto que deba mantenerse alineado con el mundo, como olas de agua en una malla rotada— hay que hacer el cálculo con positionWorld y devolver el resultado a espacio de objeto.

Los accesores de posición

Accesor Espacio Uso
positionGeometry objeto, sin modificar el atributo crudo de la geometría
positionLocal objeto, modificable lo que asignas a positionNode
positionWorld mundo efectos alineados con la escena
positionView vista cálculos relativos a la cámara
positionViewDirection dirección normalizada del fragmento hacia la cámara
positionWorldDirection dirección normalizada del rayo de vista en mundo
positionPrevious objeto, frame anterior vectores de movimiento

La distinción entre positionGeometry y positionLocal importa cuando compones: positionLocal ya incluye las modificaciones que otros sistemas hayan hecho, como el skinning o los morph targets, mientras que positionGeometry es el atributo tal como vino del buffer.

Recalcular la normal

Este es el problema real. Si desplazas la superficie sin tocar la normal, la iluminación seguirá calculándose como si la superficie fuera plana, y el efecto se verá como una textura en movimiento en lugar de como un relieve.

La técnica estándar es de diferencias finitas: desplaza dos puntos vecinos con la misma función, construye las dos tangentes, y toma su producto vectorial. Para hacerlo hace falta una base tangente, y para una geometría con UV la más práctica es la que se obtiene de los propios atributos.

import {
  Fn, float, vec3, positionLocal, normalLocal, tangentLocal, bitangentLocal,
  cross, normalize, sin, time, uniform
} from 'three/tsl';

const uAmplitud = uniform( 0.15 );

// La funcion de desplazamiento, en un solo sitio.
const altura = Fn( ( [ p ] ) => {
  return sin( p.x.mul( 5 ).add( time.mul( 2 ) ) ).mul( uAmplitud );
}, { return: 'float', p: 'vec3' } );

const EPS = 0.01;

// Posicion desplazada.
const desplazada = positionLocal.add( normalLocal.mul( altura( positionLocal ) ) ).toVar();

// Dos vecinos desplazados con la misma funcion.
const pT = positionLocal.add( tangentLocal.xyz.mul( EPS ) );
const pB = positionLocal.add( bitangentLocal.mul( EPS ) );

const vecinoT = pT.add( normalLocal.mul( altura( pT ) ) );
const vecinoB = pB.add( normalLocal.mul( altura( pB ) ) );

material.positionNode = desplazada;
material.normalNode = normalize( cross(
  vecinoT.sub( desplazada ),
  vecinoB.sub( desplazada )
) );

Cuatro observaciones sobre este código.

La función de desplazamiento vive en un Fn y se llama tres veces. Eso no es solo higiene: garantiza que los tres puntos usan exactamente la misma función, que es el requisito para que la normal sea correcta. En cuanto uno de los tres se desincroniza, la iluminación se descuadra de forma sutil y difícil de diagnosticar.

tangentLocal requiere que la geometría tenga atributo de tangentes. Si tu geometría no lo trae, la forma más simple de conseguirlo es geometria.computeTangents(), que necesita a su vez índices y UV. Para geometrías sin UV la alternativa es construir una base ortonormal arbitraria a partir de la normal, con la pérdida de que el resultado depende de esa elección.

El EPS de 0.01 es un compromiso, como el epsilon del gradiente en raymarching: demasiado pequeño y la normal se ahoga en el ruido de precisión, demasiado grande y el relieve se suaviza. Ajústalo a la escala de tu geometría.

Y el orden del producto vectorial determina el signo de la normal. Si la iluminación sale invertida —las zonas que deberían estar iluminadas están oscuras—, intercambia los dos argumentos de cross.

⚠️
Un desplazamiento visible necesita geometría suficiente

Puedes desplazar todo lo que quieras, pero solo hay vértices donde la geometría los tiene. Una PlaneGeometry de un segmento no ondula por muchos nodos que le pongas: tiene cuatro vértices. Antes de culpar al shader, comprueba la resolución de la malla. Y ten en cuenta el coste: una esfera de 256 por 256 segmentos son más de sesenta mil vértices, y multiplicar eso por un desplazamiento con recálculo de normales que evalúa la función tres veces se nota.

Los otros slots de vértice

vertexNode sustituye el vertex shader entero, incluida la proyección. Es la salida de emergencia y casi nunca es lo correcto: al usarlo pierdes el skinning, los morph targets, el instancing, el batching y la lógica de sombras, porque todo eso vive en el código que acabas de sustituir. Reserva vertexNode para casos donde de verdad quieras controlar la proyección, como un shader de billboard o una proyección no estándar, y sé consciente de lo que dejas fuera.

geometryNode es peculiar y merece una nota: no espera un nodo sino una función. Es un gancho que se ejecuta durante la construcción del grafo y permite alterar la configuración geométrica del material antes de que el resto se monte. Es de uso interno y muy poco frecuente en código de aplicación.

Y los dos slots de posición de sombra, castShadowPositionNode y receivedShadowPositionNode, existen precisamente por el problema de esta lección: cuando desplazas la geometría, el shadow map se genera con la geometría sin desplazar salvo que se lo digas. El primero sirve para que la sombra que el objeto proyecta corresponda a su forma real:

material.castShadowPositionNode = desplazada;

Sin esa línea, un terreno ondulado proyecta la sombra de un plano liso, que es un artefacto que despista mucho porque todo lo demás se ve bien.

La normal es una derivada, y por eso su coste crece con lo que quieras hacer

Aquí hay un principio que aclara toda una familia de dificultades. La posición es un dato: la tienes, la modificas, se acabó. La normal no es un dato, es una derivada de la superficie: describe cómo cambia la posición en el entorno de un punto. Y las derivadas tienen una propiedad incómoda: no se pueden componer localmente. Si desplazas un vértice, para conocer la nueva normal necesitas saber cómo se han desplazado sus vecinos, que es información que ese vértice no tiene. De ahí sale toda la incomodidad de esta lección: las tres evaluaciones de la función, la base tangente, el epsilon, la sensibilidad al signo. No es una carencia de TSL ni de Three.js; es la naturaleza del problema, y aparece igual en GLSL, en Blender y en cualquier motor. Conocer eso te da tres salidas cuando el coste se vuelve inaceptable, y conviene tenerlas presentes porque son muy distintas. La primera: si tu función de desplazamiento tiene derivada analítica, calcúlala en lugar de aproximarla por diferencias. Una onda seno tiene por derivada un coseno, y eso es una evaluación en lugar de tres. La segunda: si el desplazamiento es de alta frecuencia y baja amplitud, no desplaces geometría en absoluto y usa un mapa de normales o un bumpMap; visualmente es indistinguible en la mayoría de los casos y cuesta una fracción. La tercera, la salida bruta: usa normalFlat, que reconstruye la normal en el fragment shader con derivadas de pantalla sobre la posición ya desplazada. Es exacta por construcción, no necesita tangentes ni epsilon, y su coste es casi nulo. La contrapartida es que da una normal por triángulo, así que el resultado es facetado. Para un objeto de aspecto duro, cristalino o de baja poligonización eso no es un defecto sino el aspecto que buscabas, y te ahorras la lección entera.