Saltar al contenido

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.

El desplazamiento básico

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

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

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:

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