6. TSL y materiales de nodos
Secciones
Ajustes de lectura
Ninguna voz para este idioma en este navegador.
Más voces
Código y términos técnicos en inglés
Desactivado: la voz de la página lo lee todo, sin pausa entre dos voces.
Ninguna voz para este idioma en este navegador.
Más voces
Más voces: Microsoft Edge ofrece voces naturales en línea, y Windows (Configuración › Hora e idioma › Voz), macOS y Android instalan otras.
Código: la página en src/06-tsl/main.ts, y los shaders generados que check.sh escribe en out/l06_shader_webgpu.txt y out/l06_shader_webgl.txt.
Shaders, en tres toolkits
Sección titulada «Shaders, en tres toolkits»Un shader es un pequeño programa que se ejecuta en la GPU para cada vértice o cada píxel. En MonoGame, lo escribes en HLSL, en un archivo .fx que la canalización de contenido compila en un Effect, y asignas sus parámetros desde C#. El 3D de WPF no tiene shaders personalizados; su ShaderEffect ejecuta un pixel shader HLSL sobre un elemento renderizado, en 2D. JavaFX no tiene ninguno.
three.js ha tenido dos maneras. Con WebGLRenderer, un ShaderMaterial recibe el código fuente GLSL como cadenas. Con WebGPURenderer, una cadena GLSL no puede ejecutarse en WebGPU, que habla WGSL, y una cadena WGSL no puede ejecutarse en la alternativa WebGL 2. Por eso los materiales de WebGPURenderer se construyen con nodos, escritos en TSL, el Three Shading Language: funciones TypeScript que construyen un grafo, que el renderizador convierte en WGSL o GLSL para el backend sobre el que se ejecuta.
| MonoGame | WPF | three.js, WebGLRenderer |
three.js, WebGPURenderer |
|
|---|---|---|---|---|
| Lenguaje | HLSL | HLSL (solo pixel shaders) | cadenas GLSL | TSL, en TypeScript |
| Compilación | en tiempo de compilación, por la canalización de contenido | en tiempo de compilación, por fxc |
por el navegador, en el primer renderizado | a WGSL o GLSL por three.js, luego por el navegador, en el primer renderizado |
| Parámetros | Effect.Parameters["name"].SetValue |
propiedades de dependencia enlazadas a registros | uniforms: { name: { value } } |
nodos uniform(value), asignados con .value |
| Iluminación | te toca escribirla | ninguna | la tuya, o parches con onBeforeCompile |
la del propio material: reemplazas una entrada, como el color |
La última fila es donde TSL más cambia el trabajo. Un ShaderMaterial reemplaza el shader entero: para tener luces, sombras y niebla, las reescribes o parcheas los fragmentos GLSL de three.js. Un material de nodos conserva su modelo de iluminación y te deja conectar un nodo en una de sus entradas.
Un material de nodos con una entrada reemplazada
Sección titulada «Un material de nodos con una entrada reemplazada»El mástil de la página de esta lección recibe una veta de palisandro calculada por píxel, sin textura (main.ts, líneas 26-37):
// Palisandro: bandas oscuras y claras a lo largo del mástil, curvadas por ruido, calculadas por píxel en la GPUconst grain = Fn(() => { const p = positionWorld; const bend = mx_noise_float(vec3(p.x.mul(0.8), p.y.mul(6), 0)).mul(0.6); const bands = sin(p.y.mul(90).add(bend.mul(12))).mul(0.5).add(0.5); return mix(color(0x2a160d), color(0x5a3422), smoothstep(0.2, 0.9, bands));});const neckMaterial = new THREE.MeshStandardNodeMaterial({ roughness: 0.75 });neckMaterial.colorNode = grain();const neck = new THREE.Mesh(new THREE.PlaneGeometry(3.6, 0.9), neckMaterial);Leerlo como TypeScript confunde al principio: aquí nada calcula un color. positionWorld es un nodo que representa “la posición en el mundo del píxel que se sombrea”; p.y.mul(90) devuelve un nodo nuevo, una multiplicación; sin, mix y smoothstep son las funciones GLSL y WGSL del mismo nombre, como nodos. No hay operadores: JavaScript no tiene sobrecarga de operadores, así que a * b es a.mul(b). Fn envuelve la función constructora, y grain() la llama una vez, para construir el grafo.
MeshStandardNodeMaterial es el MeshStandardMaterial de la lección 2 como material de nodos. Su colorNode reemplaza el color base, y todo lo demás se mantiene: la rugosidad, las luces, el mapeo de tonos. Las demás entradas siguen la misma nomenclatura: normalNode, roughnessNode, metalnessNode, emissiveNode, opacityNode, y positionNode para los vértices (NodeMaterial.js, líneas 153-372). mx_noise_float es ruido Perlin del port que hizo three.js de la biblioteca de nodos de MaterialX.
Uniforms: valores que cambian sin un shader nuevo
Sección titulada «Uniforms: valores que cambian sin un shader nuevo»La cuerda es una caja fina, de 64 segmentos de largo, cuyos vértices se mueven en el vertex shader como una onda estacionaria. Su amplitud, su fase y su armónico son uniforms: valores que el shader lee, y que JavaScript puede cambiar entre fotogramas (líneas 39-57):
// La cuerda: una caja fina a lo largo de X de −1.6 a 1.6, fija en ambos extremosconst LENGTH = 3.2;const amplitude = uniform(0.2);const phase = uniform(0); // 2π × frecuencia × tiempo, asignado desde JavaScriptconst startHarmonic = Number(params.get('harmonic') ?? 1);const harmonic = uniform(startHarmonic); // 1 = fundamental, 2 = octava: un nodo en el centroconst vibrate = Fn(() => { const u = positionLocal.x.div(LENGTH).add(0.5); // 0 en un extremo, 1 en el otro const shape = sin(u.mul(PI).mul(harmonic)); // la forma de la onda estacionaria, 0 en ambos extremos const offset = amplitude.mul(shape).mul(sin(phase)); return positionLocal.add(vec3(0, offset, 0));});const stringMaterial = new THREE.MeshBasicNodeMaterial();stringMaterial.colorNode = color(0xf2efe6);stringMaterial.positionNode = vibrate();El bucle asigna phase.value en cada fotograma. Cuando se sondea, la página fija el tiempo en un cuarto de periodo, donde sin(phase) vale 1 y el centro de la cuerda está 0.2 unidades por encima de su posición de reposo:
$ node scripts/probe.mjs 06-tsl --extract shader out/l06_shader_webgpu.txt{ "frame": 4, "render": { "calls": 6, "drawCalls": 3, "triangles": 519 }, "memory": { "geometries": 3, "textures": 3, "programs": 6 }, "t": 0.25, "harmonic": 1, "offsetAtMiddle": 0.2, "nodeBuilds": { "firstFrames": 3, "afterUniformChange": 3, "afterNewNodeGraph": 4 }, "pixels": { "string middle, displaced": "rgb(242, 239, 230)", "string middle, at rest": "rgb(56, 39, 34)", "quarter length, displaced": "rgb(242, 239, 230)" }}
Página en vivo: ejecutarla aquí
Abrir a pantalla completa. Pruébalo: pon harmonic en 2, la octava del ejercicio 1, y compara la cuerda con tu predicción. Las cuerdas de la guitarra entera de la lección 5 vibran igual: un positionNode cuya amplitud, punto de inicio y tiempo son uniforms que fija un clic.
Los píxeles confirman la forma: crema en el centro desplazado y en el punto a un cuarto desplazado, y madera en la posición de reposo del centro, donde la cuerda ya no está. Tres draw calls: el mástil, la cuerda y la pasada de salida. La ejecución con --webgl da los mismos recuentos y los mismos tres colores.
Cuándo se reconstruye el shader
Sección titulada «Cuándo se reconstruye el shader»renderer.debug.onNodeBuilderCreated se llama cada vez que el renderizador convierte el grafo de nodos de un material en código de shader (Renderer.js, líneas 736-760). La página cuenta las llamadas (líneas 72-91):
- 3 construcciones en los primeros fotogramas: el mástil, la cuerda, y la pasada de salida del renderizador, que también es un material de nodos.
- Siguen siendo 3 tras
harmonic.value = 2: cambiar un uniform es escribir en un búfer de la GPU. El shader se queda. - 4 tras reemplazar
colorNodecon un grafo nuevo y activarneedsUpdate: un grafo nuevo es código nuevo, construido y compilado de nuevo. En un material grande, eso es un tirón visible.
Así que todo lo que cambia mientras la página se ejecuta va en un uniform, no en un número JavaScript fijado dentro del grafo. color(0xffb454) y float(1) son constantes: se escriben en el código fuente del shader.
En qué se convierte el grafo
Sección titulada «En qué se convierte el grafo»renderer.debug.getShaderAsync(scene, camera, object) devuelve el código generado para un objeto, para el backend sobre el que se ejecuta el renderizador. La página lo extrae, una vez de la ejecución por defecto y otra de la ejecución con --webgl. En la máquina del autor, los dos archivos contienen la misma línea en WGSL y en GLSL:
$ grep -h 'positionLocal = ' out/l06_shader_webgpu.txt out/l06_shader_webgl.txt positionLocal = position; positionLocal = ( positionLocal + vec3<f32>( 0.0, ( ( object.nodeUniform0 * sin( ( ( ( ( positionLocal.x / 3.2 ) + 0.5 ) * 3.141592653589793 ) * object.nodeUniform1 ) ) ) * sin( object.nodeUniform2 ) ), 0.0 ) ); positionLocal = position; positionLocal = ( positionLocal + vec3( 0.0, ( ( nodeUniform0 * sin( ( ( ( ( positionLocal.x / 3.2 ) + 0.5 ) * 3.141592653589793 ) * nodeUniform1 ) ) ) * sin( nodeUniform2 ) ), 0.0 ) );La misma expresión, del mismo TSL, en los dos lenguajes: object.nodeUniform0 es la amplitud, nodeUniform1 el armónico y nodeUniform2 la fase, y LENGTH, una constante JavaScript, es el literal 3.2. En WGSL, los uniforms están en un struct llamado object: un uniform() pertenece por defecto al grupo del objeto, subido para cada objeto que lo usa (UniformNode.js, línea 55). Un valor compartido por todo un fotograma, como un tiempo, puede unirse al grupo del fotograma con .setGroup(frameGroup), subido una vez por fotograma (UniformGroupNode.js, líneas 152-168).
El resto del archivo, unas 250 líneas por backend, es de three.js: el modelo de iluminación, el mapeo de tonos, los structs. Cambia entre versiones, por eso check.sh solo compara la línea GLSL. Imprime la otra sin compararla: en los runners de CI de Linux y Windows, que no tienen GPU, la ejecución por defecto recurre a WebGL 2, y su archivo también contiene GLSL.
De GLSL a TSL
Sección titulada «De GLSL a TSL»Un ShaderMaterial en GLSL tiene tres partes, y cada una tiene su equivalente en TSL:
ShaderMaterial en GLSL |
TSL |
|---|---|
uniforms: { exponent: { value: 0.6 } } |
const exponent = uniform(0.6) |
varying vec3 vWorldPosition; y el vertex shader que lo rellena |
positionWorld, calculado donde se usa; varying(node) cuando necesitas uno |
gl_FragColor = vec4(…) en main() |
un nodo devuelto a colorNode, o fragmentNode para reemplazar toda la salida |
pow(max(h, 0.0), exponent) |
pow(max(h, 0), exponent), o h.max(0).pow(exponent) |
integrados: modelMatrix, cameraPosition |
nodos: modelWorldMatrix, cameraPosition |
TSL también tiene control de flujo, If, Loop y select, variables con .toVar(), y funciones con parámetros tipados, todo descrito en la especificación de TSL de la wiki del proyecto.
En GuitarAlchemist/ga
Sección titulada «En GuitarAlchemist/ga»GA está en plena migración: ShaderMaterial aparece en 15 archivos y three/tsl en 33.
-
Un cielo GLSL, dos veces.
BSP/Sunburst3D.tsxdibuja su cielo como una gran esfera con unShaderMaterial: un vertex shader que pasa la posición en el mundo, y un fragment shader que mezcla dos colores según la altura (líneas 450-481).BSP/ImmersiveMusicalWorld.tsxtiene una copia (líneas 144-171). Ninguno puede renderizarse conWebGPURenderer, ni siquiera en su backend WebGL 2, ya que no ejecuta cadenas GLSL. El ejercicio 3 lo porta. -
Un port a TSL que funciona, con parámetros congelados.
PrimeRadiant/shaders/FresnelGlowTSL.tsreemplaza la corona GLSL del sistema solar por uncolorNode(líneas 25-44):material.colorNode = Fn(() => {const viewDir = cameraPosition.sub(positionWorld).normalize();const f = float(1.0).sub(abs(normalWorld.dot(viewDir)));const corona = pow(f, float(power)).mul(intensity);return vec3(corona.mul(color[0]), corona.mul(color[1]), corona.mul(color[2]));})();power,intensityy el color son números JavaScript, así que se convierten en constantes en el shader: cambiar la intensidad de la corona implica un material nuevo, y una construcción nueva, como se midió arriba. La fábrica de atmósferas del mismo archivo usauniformpara sus parámetros (líneas 58-81), que es el patrón a seguir cuando un valor debe cambiar. -
No es TSL, pese al nombre.
PrimeRadiant/shaders/MoebiusPassTSL.tsdice en su cabecera que está “Implemented as a Three.js ShaderPass” (línea 16), y crea unTHREE.ShaderMaterial(línea 174): GLSL, solo paraWebGLRenderer. -
Buscar el material en cada fotograma.
BSP/PortalDoor.tsxactualiza el uniformtimede su portal buscando unShaderMaterialentre los hijos en cada fotograma (líneas 218-228). Con TSL, el nodo integradotimeno necesita ninguna actualización, y ununiformguardado en una variable no necesita búsqueda.
Experimento GA
Sección titulada «Experimento GA»La lección 14 mide TSL dos veces sobre las necesidades de GA: el experimento 2 hace vibrar 600 cuerdas en el vertex shader, frente a vértices desplazados y morph targets, y el experimento 4 transpone 4 096 brazaletes de clases de altura en TSL, con 8 veces menos tiempo de CPU que en JavaScript.
Puntos clave
Sección titulada «Puntos clave»WebGPURendererno ejecuta cadenas GLSL: sus materiales son grafos de nodos, escritos en TSL y compilados a WGSL o GLSL para el backend que elige.- El código TSL construye un grafo; no calcula. Los métodos reemplazan a los operadores:
a.mul(b),a.add(b). - Un material de nodos conserva su iluminación: reemplaza una entrada,
colorNode,positionNode,normalNode, en lugar del shader entero. - Un
uniformcambia sin reconstrucción; un número JavaScript en el grafo es una constante, y un grafo nuevo es un shader nuevo.renderer.debug.onNodeBuilderCreatedcuenta las construcciones. renderer.debug.getShaderAsyncmuestra el código generado, para un objeto y un backend.
Ejercicios
Sección titulada «Ejercicios»- Sondea la página con
?harmonic=2, la octava. Antes de ejecutarlo, predice los colores en los tres puntos de muestreo, y el valor deoffsetAtMiddle. - En
out/l06_shader_webgl.txt, busca las declaraciones GLSL denodeUniform0anodeUniform2. ¿Dónde están, y en qué se diferencia del archivo WGSL? - Porta el degradado de cielo GLSL de GA a un
colorNodeTSL para unMeshBasicNodeMaterial, con los dos colores y el exponente como uniforms. No hace falta ejecutarlo, pero compara tu grafo con el GLSL línea por línea.
Solución 1
$ node scripts/probe.mjs 06-tsl --query harmonic=2 --extract shader out/l06_shader_harmonic_2.txt{ "frame": 4, "render": { "calls": 6, "drawCalls": 3, "triangles": 519 }, "memory": { "geometries": 3, "textures": 3, "programs": 6 }, "t": 0.25, "harmonic": 2, "offsetAtMiddle": 0, "nodeBuilds": { "firstFrames": 3, "afterUniformChange": 3, "afterNewNodeGraph": 4 }, "pixels": { "string middle, displaced": "rgb(242, 239, 230)", "string middle, at rest": "rgb(242, 239, 230)", "quarter length, displaced": "rgb(242, 239, 230)" }}El segundo armónico es sin(2πu): cero en el centro, donde u = 0.5, y máximo en el punto a un cuarto. Así que el centro de la cuerda se queda en reposo, y las dos muestras del centro, que ahora son el mismo punto, leen el color de la cuerda; el punto a un cuarto se desplaza los 0.2 completos y también lee el color de la cuerda. Las construcciones son las mismas que para la fundamental: el armónico es un uniform.
Solución 2
En el backend WebGL 2, los uniforms son miembros de un uniform block, declarado cerca del principio de cada shader, y se usan sin prefijo: nodeUniform0 * sin(…). En WGSL, son miembros de un struct enlazado como var<uniform> object : objectStruct en el bind group 1, y se usan como object.nodeUniform0. Ambos agrupan los uniforms por objeto en un solo búfer, para que una sola escritura los actualice todos. La numeración, y los nombres, pertenecen al node builder de three.js: no dependas de ellos fuera de la depuración.
Solución 3
import { color, float, Fn, max, mix, normalize, positionWorld, pow, uniform } from 'three/tsl';
const topColor = uniform(color(0x0077ff));const bottomColor = uniform(color(0x000033));const offset = uniform(33);const exponent = uniform(0.6);
const sky = new THREE.MeshBasicNodeMaterial({ side: THREE.BackSide });sky.colorNode = Fn(() => { // float h = normalize(vWorldPosition + offset).y; const h = normalize(positionWorld.add(offset)).y; // mix(bottomColor, topColor, max(pow(max(h, 0.0), exponent), 0.0)) return mix(bottomColor, topColor, max(pow(max(h, float(0)), exponent), 0));})();El varying desaparece: positionWorld en un nodo de fragmento es la posición en el mundo interpolada, que el vertex shader de la versión GLSL pasaba a mano. El alfa de 1 de gl_FragColor es la opacidad por defecto del material. Una diferencia que comprobar: el GLSL escribe el color mezclado en la salida tal cual, mientras que la salida de un material de nodos pasa por el mapeo de tonos y la conversión de espacio de color del renderizador, así que el cielo puede verse distinto (por verificar en una página). Para un cielo, scene.backgroundNode puede recibir el mismo tipo de nodo sin esfera. Este fragmento no se compiló en check.sh (por verificar).
Fuentes
Sección titulada «Fuentes»- Documentación de three.js: TSL,
MeshStandardNodeMaterial,MeshBasicNodeMaterial,ShaderMaterial; la especificación Three.js Shading Language de la wiki - Código fuente de three.js en r186:
NodeMaterial.js,UniformNode.js,UniformGroupNode.js,Renderer.js - W3C: WGSL; Khronos: GLSL ES 3.00
- MonoGame:
Effect; WPF:ShaderEffect