Fn: funciones reutilizables
Cómo se empaqueta un trozo de grafo en algo con parámetros, por qué el callback recibe un array, y qué hacen los layouts y once().
La forma básica
import { Fn, float, sin, TWO_PI } from 'three/tsl';const ondaSuave = Fn( ( [ x, frecuencia = float( 1 ) ] ) => { return sin( x.mul( frecuencia ).mul( TWO_PI ) ).mul( 0.5 ).add( 0.5 );} );// Se llama como cualquier funcion.const n = ondaSuave( positionLocal.x, 3 );Tres cosas que señalar.
El callback recibe un array. No ( x, frecuencia ) sino ( [ x, frecuencia ] ). Es la firma real y es obligatoria; olvidar los corchetes es el error más común al empezar.
Los valores por defecto funcionan y se escriben con los constructores de tipo: float( 1 ), vec3( 0 ), color( 0xffffff ).
Los argumentos se convierten a nodos automáticamente. En la llamada de arriba se pasa el número 3 y TSL lo envuelve. No hace falta escribir float( 3 ).
La función devuelta se comporta como una función normal de JavaScript, así que se puede exportar, importar, guardar en un objeto y componer:
// efectos/ondas.jsexport const ondaSuave = Fn( ( [ x, frecuencia = float( 1 ) ] ) => { /* ... */ } );// material.jsimport { ondaSuave } from './efectos/ondas.js';material.colorNode = mix( color( 0x000000 ), color( 0xffffff ), ondaSuave( uv().x, 4 ) );La forma por objeto
Para funciones con muchos parámetros, la forma posicional se vuelve ilegible en el punto de llamada. TSL admite también parámetros con nombre:
const rejilla = Fn( ( { coord, celdas, grosor } ) => { const g = coord.mul( celdas ).fract().sub( 0.5 ).abs(); return min( g.x, g.y ).smoothstep( 0, grosor );} );const linea = rejilla( { coord: uv(), celdas: 12, grosor: 0.05 } );La elección la hace la propia llamada: si el primer argumento es un objeto plano, se usan los nombres; si es un nodo, se usan las posiciones. Para funciones de uno o dos parámetros la forma posicional es más corta; a partir de tres, la de objeto se lee mucho mejor y no se rompe al reordenar.
Layouts: cuándo se emite una función de verdad
Por defecto, una Fn se expande en línea: su cuerpo se inyecta en el punto de llamada, como si hubieras escrito la expresión ahí. Eso es lo correcto para funciones pequeñas y es lo que permite que TSL infiera tipos con libertad, porque cada llamada puede tener tipos de entrada distintos.
Cuando declaras un layout, cambia el trato: TSL emite una función de verdad en el shader generado, con su firma y sus tipos, y las llamadas pasan a ser llamadas.
const brdfSimple = Fn( ( [ n, l, v, rugosidad ] ) => { const h = normalize( l.add( v ) ); const nDotH = dot( n, h ).max( 0 ); return pow( nDotH, rugosidad.oneMinus().mul( 128 ) );}, { return: 'float', n: 'vec3', l: 'vec3', v: 'vec3', rugosidad: 'float'} );El objeto de layout usa la clave return para el tipo devuelto y el resto de claves como nombres y tipos de los parámetros, en orden. También existe la forma larga con { name, type, inputs } para controlar el nombre emitido.
Y hay una variante mínima: pasar solo una cadena fija el tipo de retorno sin generar una función aparte.
const escalar = Fn( ( [ v ] ) => v.length(), 'float' );¿Cuándo declarar un layout? Cuando la función es larga y se llama desde varios sitios, para no duplicar su cuerpo en el código emitido; cuando quieres que aparezca con nombre en el shader generado para poder leerlo al depurar; y cuando quieres forzar tipos concretos en lugar de dejar que se infieran. La contrapartida es que pierdes el polimorfismo: una función con layout de vec3 solo acepta vec3.
once, para cálculos globales
.once() marca una función para que su resultado se evalúe una sola vez por construcción de shader y se reutilice en todas las llamadas. Es lo que usa Three.js internamente para los accesores de cámara y de modelo, que son constantes durante todo el shader.
const baseTangente = Fn( () => { // Un calculo caro que no depende de nada variable. const t = normalize( cross( normalWorld, vec3( 0, 1, 0 ) ) ); return mat3( t, cross( normalWorld, t ), normalWorld );} ).once();// Las tres llamadas comparten un unico calculo.const a = baseTangente().mul( v1 );const b = baseTangente().mul( v2 );const c = baseTangente().mul( v3 );No confundir con .toVar(). .toVar() materializa un nodo concreto en una variable del shader; .once() hace que una función entera se evalúe una vez y su nodo resultado se comparta. El primero opera sobre valores, el segundo sobre definiciones.
Un ejemplo que junta las piezas
import { Fn, float, vec3, uv, mix, color, smoothstep, fract, abs, min } from 'three/tsl';// Distancia con signo a un circulo en espacio UV.const sdCirculo = Fn( ( [ p, r ] ) => p.length().sub( r ), { return: 'float', p: 'vec2', r: 'float'} );// Un anillo antialiaseado a partir de cualquier campo de distancia.const anillo = Fn( ( { d, grosor, suavidad } ) => { return abs( d ).smoothstep( grosor, grosor.sub( suavidad ) );} );const p = uv().sub( 0.5 );const d = sdCirculo( p, 0.3 );material.colorNode = mix( color( 0x0b0d14 ), color( 0xff7a3d ), anillo( { d, grosor: float( 0.02 ), suavidad: float( 0.005 ) } ));Fíjate en abs( d ).smoothstep( grosor, grosor.sub( suavidad ) ). Aquí sí es correcto encadenar smoothstep, porque su receptor es el valor de entrada y los argumentos son los bordes. Y los bordes van en orden decreciente a propósito: eso invierte la rampa, que es el truco habitual para convertir una distancia en una máscara.