El primer shader en TSL
Una escena completa que funciona: imports correctos, renderer inicializado, un material de nodos con color procedural y vértices animados.
Toda la teoría cabe en un ejemplo que se ejecuta. Vamos a montar una escena mínima pero completa, con las importaciones correctas, la inicialización asíncrona que el renderer exige, y un material que sustituye dos partes de su shader sin perder nada del PBR. Es el mismo efecto que costó una lección entera de cirugía de cadenas en el nivel treinta y cinco.
- Montar una escena con
WebGPURenderery un material de nodos funcionando. - Sustituir el color de un
MeshStandardNodeMaterialpor una expresión procedural. - Animar la posición de los vértices desde un nodo.
- Actualizar un uniform de TSL desde JavaScript.
La escena completa
import * as THREE from 'three/webgpu';
import {
color, mix, sin, time, uniform, positionLocal, normalLocal
} from 'three/tsl';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
const renderer = new THREE.WebGPURenderer( { antialias: true } );
renderer.setPixelRatio( Math.min( devicePixelRatio, 2 ) );
renderer.setSize( innerWidth, innerHeight );
renderer.toneMapping = THREE.ACESFilmicToneMapping;
document.body.appendChild( renderer.domElement );
const escena = new THREE.Scene();
escena.background = new THREE.Color( 0x0b0d14 );
const camara = new THREE.PerspectiveCamera( 45, innerWidth / innerHeight, 0.1, 100 );
camara.position.set( 0, 1.5, 5 );
const controles = new OrbitControls( camara, renderer.domElement );
controles.enableDamping = true;
escena.add( new THREE.HemisphereLight( 0x99bbff, 0x221100, 0.6 ) );
const sol = new THREE.DirectionalLight( 0xffffff, 2.5 );
sol.position.set( 3, 5, 2 );
escena.add( sol );
// --- El material ---
const uAmplitud = uniform( 0.12 );
const material = new THREE.MeshStandardNodeMaterial( {
roughness: 0.35,
metalness: 0.1
} );
// Color: dos tonos mezclados por una onda que recorre la geometria.
const onda = sin( positionLocal.x.mul( 4 ).add( time.mul( 1.5 ) ) ).mul( 0.5 ).add( 0.5 );
material.colorNode = mix( color( 0x1f4fd8 ), color( 0xff7a3d ), onda );
// Vertices: desplazamiento a lo largo de la normal, con la misma onda.
material.positionNode = positionLocal.add( normalLocal.mul( onda ).mul( uAmplitud ) );
const malla = new THREE.Mesh(
new THREE.SphereGeometry( 1.2, 128, 128 ),
material
);
escena.add( malla );
// --- Bucle ---
function redimensionar() {
renderer.setSize( innerWidth, innerHeight );
camara.aspect = innerWidth / innerHeight;
camara.updateProjectionMatrix();
}
addEventListener( 'resize', redimensionar );
renderer.setAnimationLoop( () => {
controles.update();
renderer.render( escena, camara );
} );
Funciona tal cual, y merece la pena señalar lo que no hay: ni una línea de GLSL, ni un onBeforeCompile, ni una clave de caché, ni un ancla de sustitución. Y sin embargo el material sigue siendo un MeshStandardNodeMaterial completo: recibe las dos luces, respeta la rugosidad, respeta el metalness, participaría en las sombras y en la iluminación de entorno si las hubiera.
Fíjate también en que no hay await renderer.init(). Es correcto porque setAnimationLoop inicializa el renderer por dentro antes de ejecutar el primer callback. Si en lugar de un bucle continuo quisieras renderizar bajo demanda, sí haría falta esperar la inicialización, y eso se ve en el nivel de WebGPURenderer.
Leyendo el material línea a línea
const onda = sin( positionLocal.x.mul( 4 ).add( time.mul( 1.5 ) ) ).mul( 0.5 ).add( 0.5 );
positionLocal es la posición del vértice en espacio de objeto, la misma que position en GLSL. .x extrae la componente. .mul( 4 ) fija la frecuencia espacial. time es un uniforme que Three.js actualiza solo en cada frame: no hay que declararlo ni alimentarlo. .mul( 1.5 ) es la velocidad. sin() da un valor entre menos uno y uno, y el .mul( 0.5 ).add( 0.5 ) final lo lleva al rango de cero a uno, que es el que necesita una mezcla.
material.colorNode = mix( color( 0x1f4fd8 ), color( 0xff7a3d ), onda );
colorNode sustituye el color base del material. mix( a, b, t ) interpola desde a hacia b según t, con el mismo orden de argumentos que en GLSL. color() construye un nodo de color a partir de un hexadecimal, un THREE.Color o tres componentes.
material.positionNode = positionLocal.add( normalLocal.mul( onda ).mul( uAmplitud ) );
positionNode sustituye la posición del vértice. Desplazar a lo largo de normalLocal infla y desinfla la esfera siguiendo la misma onda. Y uAmplitud, que es un uniform, controla la magnitud desde JavaScript.
mix( a, b, t ) como función tiene el orden que esperas. Pero el método encadenado no es equivalente: en r184, t.mix( a, b ) se traduce a mix( a, b, t ), es decir, el receptor del método es el factor de interpolación, no el primer color. Lo mismo pasa con .step() y .smoothstep(). Es una de las trampas más fáciles de pisar de la API y tiene su propia sección en el nivel siguiente. Mientras tanto, usa siempre la forma funcional para estas tres, que no tiene ambigüedad.
Cambiar un uniform en caliente
Un uniform() de TSL guarda su valor en la propiedad .value, igual que un uniform clásico:
uAmplitud.value = 0.3;
Y como es un objeto normal, se puede enganchar directamente a un panel de control:
import GUI from 'three/addons/libs/lil-gui.module.min.js';
const gui = new GUI();
gui.add( uAmplitud, 'value', 0, 0.5, 0.001 ).name( 'Amplitud' );
Los uniformes de color se construyen desde un THREE.Color y se mutan con sus métodos:
const uTinte = uniform( new THREE.Color( 0xff7a3d ) );
material.colorNode = mix( color( 0x1f4fd8 ), uTinte, onda );
// Mas tarde:
uTinte.value.set( 0x33ff88 );
La diferencia clave con un valor literal es que cambiar un uniform no recompila nada. color( 0xff7a3d ) es una constante que se hornea en el código generado; uniform( new THREE.Color( 0xff7a3d ) ) es un dato que viaja a la GPU en cada frame. La regla es la misma que con GLSL: lo que cambia va en un uniform, lo que no cambia va como constante y le da más margen al compilador.
El cambio más profundo de este ejemplo no es sintáctico y es fácil pasarlo por alto: el shader ha dejado de ser un artefacto separado. Con GLSL, el shader vive en una plantilla de cadena, en un fichero .glsl importado por un plugin del empaquetador, o en un <script> con un tipo inventado. Sea cual sea la variante, es un territorio aparte, con sus propias reglas, sin autocompletado, sin comprobación de tipos, y comunicándose con tu aplicación exclusivamente a través de un diccionario de uniformes con nombres emparejados por convención. Ese diccionario es el punto de fallo silencioso clásico de todo Three.js: escribes mal un nombre en un sitio y el uniform se queda a cero sin que nada avise. En TSL ese diccionario desaparece. uAmplitud es una variable de JavaScript con su alcance normal, y la relación entre el bucle de render y el shader es una referencia de objeto real, no una cadena que hay que mantener sincronizada a mano. Renombrarla desde el editor renombra las dos puntas. Un import roto es un error de módulo, no un uniform silenciosamente nulo. Un factor de la mezcla puede venir de un import de otro fichero, de un cálculo, de un map sobre una lista de capas. Y la consecuencia organizativa es la que de verdad cambia proyectos grandes: puedes empaquetar un efecto como un módulo que exporta un nodo, con sus uniformes públicos, sus parámetros y su documentación, y consumirlo desde cinco materiales distintos como consumirías cualquier otra dependencia. Eso es composición de verdad, y es lo que con shaders escritos como texto nunca se llegó a conseguir: lo más cerca que se estuvo fue concatenar cadenas y rezar por que los nombres no colisionaran.