Qué es Three.js Shading Language
Qué clase de cosa es TSL exactamente, de dónde se importa, qué relación tiene con GLSL y WGSL, y por qué no es una librería de utilidades.
Un lenguaje que vive dentro de otro
Esta expresión no calcula nada:
import { uv, mix, color } from 'three/tsl';const gradiente = mix( color( 0x2266ff ), color( 0xff6622 ), uv().x );No hay ningún píxel involucrado, no hay GPU, no se ha evaluado ninguna interpolación. Lo que gradiente contiene es un objeto: un nodo de tipo mezcla que apunta a dos nodos de color y a un nodo que extrae la componente X de un nodo de coordenadas de textura. Un árbol de cuatro nodos, construido en la CPU, en tiempo de JavaScript.
Cuando ese árbol se asigna a un material y el material se usa para dibujar algo, el motor lo recorre y genera código fuente de shader. Es en ese momento, y no antes, cuando aparece GLSL o WGSL.
La técnica tiene nombre en la literatura: es un lenguaje específico de dominio incrustado, y es el mismo patrón que usan las librerías de consultas SQL tipadas, los constructores de expresiones regulares y los sistemas de shaders de muchos motores. La idea común es que en lugar de escribir el lenguaje destino como texto, escribes en el lenguaje anfitrión una estructura que lo representa, y ganas todo lo que el anfitrión te da: composición, funciones, tipos, herramientas.
La diferencia con una simple librería de utilidades es que TSL no se ejecuta en el mismo sitio que el código que la construye. Una función auxiliar que devuelve un número calcula en la CPU y devuelve un número. mix() no devuelve un color: devuelve la promesa de una mezcla que ocurrirá en la GPU, millones de veces por frame, con valores que ahora mismo no existen.
De dónde se importa cada cosa
Los puntos de entrada son dos, y confundirlos es el primer obstáculo. En el package.json de three r184 el mapa de exportaciones es literalmente:
"exports": { ".": { "import": "./build/three.module.js", "require": "./build/three.cjs" }, "./addons": "./examples/jsm/Addons.js", "./addons/*": "./examples/jsm/*", "./webgpu": "./build/three.webgpu.js", "./tsl": "./build/three.tsl.js"}Es decir:
// Las funciones y nodos del lenguaje.import { uv, mix, color, Fn, uniform, positionLocal } from 'three/tsl';// Los materiales de nodos, el renderer, y el resto del nucleo.import * as THREE from 'three/webgpu';import { MeshStandardNodeMaterial, WebGPURenderer } from 'three/webgpu';Hay un detalle que ahorra confusión: three/tsl es un envoltorio delgado sobre three/webgpu. Su fuente empieza literalmente con import { TSL } from 'three/webgpu' y sigue con una lista plana de reexportaciones. No son dos librerías: es la misma, con dos superficies distintas. Por eso importar de las dos en el mismo fichero no duplica nada.
Y una consecuencia práctica que sorprende a casi todo el mundo: los node materials no están en three. import { MeshStandardNodeMaterial } from 'three' no funciona. src/Three.js exporta el núcleo, WebGLRenderer y los extras de WebGL; los materiales de nodos y WebGPURenderer viven exclusivamente en three/webgpu. Migrar a TSL implica, como mínimo, cambiar el punto de entrada de tu importación principal.
Los tres nombres que verás siempre
Casi cualquier fragmento de TSL que encuentres usa alguna de estas tres cosas, así que conviene reconocerlas desde el principio aunque su detalle venga después.
Constantes de acceso. Nombres que representan datos que la GPU ya tiene: positionLocal, normalLocal, normalView, positionWorld, cameraPosition, modelViewMatrix, time. Se usan directamente, sin paréntesis, porque son valores, no funciones.
material.positionNode = positionLocal.mul( 1.2 );Funciones constructoras y operaciones. Nombres que se invocan: uv(), color(), vec3(), float(), uniform(), attribute(), texture(), mix(), smoothstep(), sin().
const c = mix( color( 0x000000 ), color( 0xffffff ), uv().y );Encadenamiento de métodos. Casi toda operación existe también como método sobre cualquier nodo, lo que permite leer de izquierda a derecha:
// Estas dos lineas construyen el mismo grafo.const a = mul( add( positionLocal.x, 1 ), 0.5 );const b = positionLocal.x.add( 1 ).mul( 0.5 );Esa dualidad es deliberada y viene de que Three.js registra cada operación tanto como función suelta como método del prototipo de Node. Cuál usar es cuestión de legibilidad; el grafo resultante es idéntico.
Y un cuarto elemento que aparece en cuanto el código crece:
Fn(), que empaqueta un trozo de grafo en algo reutilizable con parámetros, y que es el equivalente de una función de GLSL.
import { Fn, float } from 'three/tsl';const ondas = Fn( ( [ p, frecuencia ] ) => { return p.mul( frecuencia ).sin().mul( 0.5 ).add( 0.5 );} );Fíjate en la firma: el callback recibe un array que se desestructura. Es una peculiaridad de la API que confunde la primera vez y que tiene su razón, y la verás con detalle en el nivel siguiente.