Saltar al contenido

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.

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:

JS
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

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:

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

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