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().
En cuanto un shader pasa de veinte líneas hace falta poder darle nombre a las partes. Fn es la construcción que lo permite, y tiene una firma que confunde la primera vez: el callback recibe sus parámetros como un array que hay que desestructurar. Detrás de esa rareza hay un mecanismo con una consecuencia importante sobre si tu función acaba siendo una función de verdad en el shader generado o se expande en línea.
- Escribir una función TSL con parámetros y valores por defecto.
- Elegir entre la forma posicional y la forma por objeto.
- Declarar un layout y explicar qué cambia en el código emitido.
- Usar
.once()para cálculos globales que solo deben evaluarse una vez.
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.js
export const ondaSuave = Fn( ( [ x, frecuencia = float( 1 ) ] ) => { /* ... */ } );
// material.js
import { 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.
Si asignas la función en lugar del resultado de llamarla —material.colorNode = miFuncion en vez de material.colorNode = miFuncion()— TSL lo detecta y emite un mensaje claro: TSL: "Fn()" was declared but not invoked. Try calling it like "Fn()( ...params )". Es fácil de cometer cuando la función no tiene parámetros, porque visualmente parece un valor.
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.
Que una Fn sin layout se expanda en línea en lugar de emitir una función parece una carencia —“todavía no lo han implementado bien”— y es justo lo contrario: es la decisión que hace posible casi todo lo demás. Piensa qué hace falta para emitir una función de verdad en GLSL o en WGSL: hay que declarar el tipo de cada parámetro y el de retorno antes de saber con qué se va a llamar. Y en un sistema donde los tipos se infieren de las entradas, eso es imposible en el caso general: la misma Fn de mezcla puede recibir un float en una llamada, un vec3 en otra y un vec4 en una tercera, y las tres son legítimas. GLSL resuelve ese problema con sobrecarga, escribiendo la función tres veces. TSL lo resuelve expandiendo en línea, que es lo mismo que hacen las plantillas en C++ y los genéricos monomorfizados en Rust: una instancia distinta por combinación de tipos, generada bajo demanda. De ahí salen dos cosas prácticas. La primera es que tu biblioteca de utilidades TSL es genérica gratis: escribes const suave = Fn(([x]) => x.mul(x).mul(x.mul(-2).add(3))) y sirve para escalares, vectores y colores sin tocar nada. La segunda es el coste, que hay que tener presente: una función expandida en línea aparece tantas veces en el shader como se llame, y una función cara llamada quince veces produce un shader quince veces más largo, con su tiempo de compilación y su presión de registros. El layout es la palanca para pasar de un modelo al otro, y ahora tiene una regla clara: déjalo por defecto para todo lo pequeño y genérico, y decláralo en cuanto una función sea larga y se llame más de dos o tres veces con los mismos tipos. Es exactamente el mismo compromiso entre inline y llamada que existe en cualquier compilador, solo que aquí lo decides tú.