wandres.dev
TSL I · El lenguaje de shading de Three.js

El grafo de nodos y su compilación

Qué es exactamente un nodo, cómo el motor recorre el grafo en fases, y por qué la misma estructura produce GLSL para un backend y WGSL para el otro.

⏱ 18 min

Entre la expresión que escribes en JavaScript y el texto que la GPU compila hay una pieza con nombre: el constructor de nodos. Recorre el grafo varias veces, con propósitos distintos en cada pasada, y solo en la última escribe código. Saber qué hace en cada fase es lo que convierte los mensajes de error de TSL en información útil en lugar de ruido.

🎯 Al terminar esta lección sabrás
  • Describir el papel de un nodo y qué información aporta al generador.
  • Enumerar las fases del recorrido del grafo y qué ocurre en cada una.
  • Explicar cómo una misma estructura produce dos lenguajes distintos.
  • Localizar el generador concreto que corresponde a cada backend.

Qué es un nodo

Un nodo es un objeto con dos responsabilidades: decir de qué tipo es y saber generarse. Todo lo demás son referencias a otros nodos.

Un nodo de suma tiene dos hijos y declara que su tipo es el mayor de los dos. Un nodo de longitud tiene un hijo y declara que su tipo es float, sea cual sea la entrada. Un nodo de producto vectorial declara vec3 siempre. Un nodo de uniforme no tiene hijos, guarda un valor y declara el tipo que se le dio al crearlo. Esa disciplina —cada nodo sabe su tipo en función de sus entradas— es lo que permite que TSL infiera tipos sin que tú los declares casi nunca.

Cuando encadenas operaciones, cada llamada añade un nodo que apunta al anterior:

const n = positionLocal.x.mul( 3 ).sin().mul( 0.5 ).add( 0.5 );

Eso construye cinco nodos anidados. positionLocal es una constante de acceso; .x crea un nodo de división de componentes; .mul(3) un nodo de operador con una constante; .sin() un nodo matemático; y así hasta arriba. El valor de n es el nodo más externo, y desde él se llega a todos los demás.

El recorrido en fases

El constructor de nodos no genera código de una pasada. Recorre el grafo tres veces, con un propósito distinto cada vez.

Fase de preparación. Cada nodo tiene la oportunidad de sustituirse por un subgrafo más elemental. Es donde oneMinus(x) se convierte en sub(1, x), donde reciprocal(x) se convierte en div(1, x), y donde los nodos de alto nivel de los materiales se expanden en sus componentes. Al terminar, el grafo solo contiene operaciones que el generador sabe emitir directamente.

Fase de análisis. Se recorre contando cuántas veces se usa cada nodo y en qué etapa del shader. De ahí sale la información que decide si un subgrafo compartido merece una variable temporal o se puede duplicar en línea, y si un valor calculado en el vértice tiene que viajar al fragmento como varying.

Fase de generación. La única que produce texto. Cada nodo emite su fragmento de código en el lenguaje del backend activo y devuelve el nombre de la variable donde dejó el resultado.

Esa separación explica por qué los errores de TSL a veces aparecen “tarde”, cuando dibujas el objeto y no cuando construyes el grafo. Construir es barato y no valida casi nada; el análisis de tipos ocurre en el recorrido, y el recorrido ocurre la primera vez que el material se usa para renderizar.

Dos generadores, un grafo

flowchart TB
A[Grafo de nodos construido en JavaScript] --> B[NodeBuilder recorre preparacion analisis generacion]
B --> C[GLSLNodeBuilder]
B --> D[WGSLNodeBuilder]
C --> E[GLSL ES 300 para el backend WebGL2]
D --> F[WGSL para el backend WebGPU]
E --> G[La misma imagen]
F --> G
style A fill:#cba6f7,color:#11111b
style B fill:#89b4fa,color:#11111b
style C fill:#89b4fa,color:#11111b
style D fill:#89b4fa,color:#11111b
style E fill:#fab387,color:#11111b
style F fill:#fab387,color:#11111b
style G fill:#a6e3a1,color:#11111b

La pieza que cambia entre las dos ramas es la subclase del constructor. GLSLNodeBuilder sabe emitir GLSL ES 3.0 con sus uniforms sueltos y sus sampler2D; WGSLNodeBuilder sabe emitir WGSL con sus @group y @binding, sus estructuras de entrada y salida, y su separación entre textura y muestreador. El grafo que reciben es exactamente el mismo objeto.

Donde los dos lenguajes divergen, el generador decide con conocimiento de la intención. Un ejemplo que puedes leer literalmente en MathNode:

if ( coordinateSystem === WebGPUCoordinateSystem && method === MathNode.ATAN && b !== null ) {

  method = 'atan2';

}

El nodo sabe que es una arcotangente con dos argumentos. Con el sistema de coordenadas de WebGPU emite atan2; con el de WebGL emite atan, que en GLSL está sobrecargada. Ninguna de las dos decisiones podría tomarse de forma fiable analizando texto.

El mismo fichero contiene otros ajustes del mismo estilo: en el sistema de coordenadas de WebGL, step, min y max reciben un tratamiento especial cuando uno de sus argumentos es escalar y el otro vectorial, porque las reglas de sobrecarga de los dos lenguajes no coinciden. Son exactamente los detalles que una traducción a mano olvida y que producen diferencias sutiles entre plataformas.

Quién elige el backend

En r184 la elección la hace el renderer, no tú. WebGPURenderer construye un WebGPUBackend salvo que le pases forceWebGL, y durante su inicialización puede caer al backend de WebGL si el adaptador de WebGPU no está disponible. El generador de nodos que se usa es el que corresponde al backend que acabó activo.

import { WebGPURenderer } from 'three/webgpu';

// Backend WebGPU si es posible, WebGL2 si no. El mismo grafo TSL vale para ambos.
const renderer = new WebGPURenderer( { antialias: true } );
await renderer.init();

console.log( renderer.backend.constructor.name );   // WebGPUBackend o WebGLBackend

Ese console.log solo dice la verdad después de init(), porque la sustitución del backend ocurre dentro de la inicialización. Es el detalle central del nivel de WebGPURenderer.

💡
Para ver el código generado, mira el material tras el primer render

Los objetos de nodo llevan la información del programa construido, así que la forma directa de auditar lo que TSL ha emitido es inspeccionar el material desde la consola después de que la escena haya dibujado al menos un frame. Es la herramienta equivalente al volcado del shader expandido del nivel treinta y cinco, y sirve para lo mismo: comprobar que lo que creías estar escribiendo es lo que la GPU acabó ejecutando.

La fase de análisis es la que decide tu rendimiento, y es la que nadie mira

De las tres fases, la de generación es la que suena importante y la de análisis es la que decide si tu shader va rápido. El problema que resuelve es este: cuando un subgrafo aparece en dos sitios del árbol —porque guardaste una expresión en una variable de JavaScript y la usaste dos veces— hay dos formas de emitirlo, y son muy distintas. Se puede duplicar en línea, escribiendo el cálculo dos veces, o se puede materializar en una variable temporal y referenciarla. Duplicar es mejor para expresiones baratas, porque evita presión de registros y le da margen al compilador del driver para reordenar. Materializar es mejor para expresiones caras, porque las calcula una sola vez. La fase de análisis es la que cuenta usos y toma esa decisión, y lo hace con reglas generales que aciertan la mayoría de las veces. Pero solo la mayoría. El caso donde falla duele: una expresión cara que se usa dos veces dentro de ramas distintas de un condicional, o dentro de un bucle, puede acabar duplicada, y de pronto tu shader hace el doble de trabajo del que escribiste sin que nada en tu código lo sugiera. Por eso .toVar() no es azúcar sintáctico ni una comodidad para nombrar cosas: es la instrucción explícita de “materializa esto aquí, en este punto del flujo, y no lo recalcules”. Y por eso los shaders TSL bien escritos están salpicados de .toVar() en los sitios exactos donde un cálculo caro se reutiliza. La regla operativa que te ahorrará muchas sorpresas es simple: si guardas una expresión en una variable de JavaScript con la intención de reutilizarla, y esa expresión cuesta más que un par de multiplicaciones, ponle .toVar(). Estás pasando del ámbito de JavaScript, donde una variable siempre significa “calculado una vez”, al ámbito del grafo, donde una variable de JavaScript no significa nada en absoluto: solo es un puntero a un nodo que puede acabar emitido tantas veces como se referencie.