wandres.dev
TSL II · Nodos y composición

uniform, attribute y varying

Las tres entradas de datos de un shader en su versión de nodos: cómo se declaran, cómo se actualizan y cómo se cruza la frontera entre vértice y fragmento.

⏱ 18 min

Un shader necesita datos de tres procedencias: valores iguales para todo el objeto, valores propios de cada vértice, y valores que un vértice calcula para que el fragmento los reciba interpolados. TSL conserva esa estructura porque es la del hardware, pero la expresa de forma que el emparejamiento por nombre —el punto de fallo silencioso clásico— desaparece.

🎯 Al terminar esta lección sabrás
  • Declarar uniformes de TSL y actualizarlos desde el bucle de render.
  • Leer atributos de geometría propios desde un nodo.
  • Cruzar datos del vértice al fragmento con varying y sus variantes.
  • Usar uniformArray para pasar colecciones de valores.

uniform

import { uniform } from 'three/tsl';

const uIntensidad = uniform( 1.0 );
const uColor      = uniform( new THREE.Color( 0xff6622 ) );
const uCentro     = uniform( new THREE.Vector3() );

La firma es uniform( value, type ), con el tipo opcional: se infiere del valor. Los tipos que puede inferir son los habituales de Three.js —número, Color, Vector2, Vector3, Vector4, Matrix3, Matrix4— y hay una forma especial que declara solo el tipo sin valor inicial, uniform( 'float' ).

Actualizarlo es escribir en .value:

uIntensidad.value = 0.4;
uColor.value.set( 0x33ff88 );
uCentro.value.copy( malla.position );

Para valores que dependen del frame o del objeto hay dos ganchos que evitan tener que acordarse de actualizarlos:

// Se ejecuta una vez por frame.
const uLatido = uniform( 0 ).onRenderUpdate( ( frame ) => Math.sin( frame.time * 3 ) * 0.5 + 0.5 );

// Se ejecuta una vez por objeto que use este material.
const uAltura = uniform( 0 ).onObjectUpdate( ( { object } ) => object.position.y );

Si el callback devuelve un valor, se asigna a .value automáticamente. Es exactamente el mecanismo con el que Three.js implementa el nodo time, que no es más que un uniforme con un onRenderUpdate que lee el reloj del frame.

Para dar nombre al uniforme en el código generado, y facilitar así la lectura del shader al depurar:

const uVelocidad = uniform( 1.0 ).setName( 'velocidad' );

En r184 label() sigue existiendo pero avisa: fue sustituida por setName() en r179.

uniformArray

Para colecciones hay un nodo específico, con acceso por índice:

import { uniformArray } from 'three/tsl';

const uPaleta = uniformArray( [
  new THREE.Color( 0x1f4fd8 ),
  new THREE.Color( 0xff7a3d ),
  new THREE.Color( 0x33ff88 )
] );

// Acceso con un indice, que puede ser un nodo.
const c = uPaleta.element( indice );

Los elementos del array que le pasas siguen siendo los mismos objetos, así que mutarlos actualiza el uniforme: uPaleta.array[ 1 ].set( 0x00ffcc ) cambia el segundo color sin reconstruir nada. Es la forma correcta de pasar una paleta o una lista de posiciones de influencia sin gastar un uniforme por elemento.

attribute

Los atributos de vértice se leen por nombre, igual que en GLSL, pero declarando el tipo si el motor no lo conoce:

import { attribute } from 'three/tsl';

const aSemilla = attribute( 'semilla', 'float' );
const aColor   = attribute( 'color', 'vec3' );

Y del lado de la geometría, nada cambia respecto a lo que ya sabes:

const geometria = new THREE.SphereGeometry( 1, 64, 64 );

const n = geometria.attributes.position.count;
const semillas = new Float32Array( n );
for ( let i = 0; i < n; i ++ ) semillas[ i ] = Math.random();

geometria.setAttribute( 'semilla', new THREE.BufferAttribute( semillas, 1 ) );

Los atributos estándar tienen su propio accesor y no hace falta pedirlos por nombre. uv() es literalmente attribute( 'uv', 'vec2' ) con soporte para canales adicionales: uv( 1 ) devuelve uv1, uv( 2 ) devuelve uv2. Y positionGeometry, normalGeometry y compañía cubren el resto.

⚠️
Un atributo solo existe en la etapa de vértice

En el hardware, los atributos de vértice no llegan al fragment shader. Si usas attribute(...) en una expresión que acaba en colorNode, TSL construye automáticamente el varying necesario para que el dato cruce. Funciona, pero conviene saber que ahí se está creando una interpolación implícita, con su coste y su semántica: el valor que llega al fragmento no es el del vértice, es la media ponderada de los tres vértices del triángulo.

varying

Un varying es un valor que se calcula en el vértice y llega al fragmento interpolado. En TSL hay tres formas de crearlo, con matices distintos.

import { varying, positionLocal, normalLocal } from 'three/tsl';

// Forma funcional, con nombre opcional.
const vAltura = varying( positionLocal.y, 'vAltura' );

// Forma encadenada, equivalente.
const vAltura2 = positionLocal.y.toVarying( 'vAltura' );

// Forzar el calculo a la etapa de vertice sin nombrarlo.
const vNormal = normalLocal.toVertexStage();

vertexStage( node ) es un alias funcional de lo mismo: internamente llama a varying( node ).

La razón de forzar un cálculo al vértice no es solo obtener el valor en el fragmento: es rendimiento. Una malla de diez mil vértices dibujada a pantalla completa tiene del orden de dos millones de fragmentos. Todo lo que se pueda calcular por vértice cuesta doscientas veces menos que calcularlo por fragmento. El compromiso es la precisión: la interpolación lineal de un valor no lineal introduce error, que se nota más cuanto más grandes son los triángulos.

import { mx_fractal_noise_float } from 'three/tsl';

// Caro: el ruido se evalua una vez por fragmento.
material.colorNode = mix(
  color( 0x101820 ), color( 0xffffff ),
  mx_fractal_noise_float( positionLocal.mul( 3 ), 5 )
);

// Barato: se evalua por vertice y llega interpolado.
material.colorNode = mix(
  color( 0x101820 ), color( 0xffffff ),
  mx_fractal_noise_float( positionLocal.mul( 3 ), 5 ).toVertexStage()
);

property y varyingProperty

Cuando necesitas una variable declarada explícitamente con un tipo y un nombre —normalmente para interoperar con nodos del sistema o para depurar— hay dos constructores más:

import { property, varyingProperty } from 'three/tsl';

const tmp = property( 'vec3', 'miTemporal' );
const vDato = varyingProperty( 'float', 'vDato' );

Son de uso menos frecuente que los tres anteriores y aparecen sobre todo dentro de la propia biblioteca.

El emparejamiento por nombre era el punto de fallo, y ha desaparecido

Merece la pena nombrar explícitamente lo que se ha ganado aquí, porque es una de esas mejoras que no se ven hasta que llevas un tiempo sin sufrir el problema. En el modelo clásico, un shader y su aplicación se comunican por coincidencia de cadenas. Declaras uniform float uTiempo en el GLSL y escribes uniforms.uTiempo = { value: 0 } en el JavaScript, y lo único que une esas dos cosas es que los dos textos son iguales. Si te equivocas en una letra, no pasa nada visible: el uniform se queda a cero, el shader compila, y el efecto simplemente no se mueve. Si renombras uno de los dos, tampoco pasa nada visible. Si un compañero cambia el nombre en el shader, tu código sigue compilando. Y el compilador de GLSL elimina los uniformes que no se usan, así que un uniform que existe en la fuente puede no existir en el programa enlazado, y una comprobación ingenua de “existe la ubicación” tampoco te salva. Esa clase de fallo ha costado incontables horas a todo el que ha escrito shaders, y es estructural: es lo que pasa cuando dos sistemas de tipos distintos se comunican por texto. En TSL el texto ha desaparecido de la ecuación. uIntensidad es una constante de JavaScript, y la referencia que el grafo guarda es al mismo objeto. Renombrarla desde el editor renombra las dos puntas porque solo hay una punta. Borrarla rompe la compilación del módulo. Escribirla mal es un ReferenceError inmediato. Y setName(), que sí toma una cadena, no participa en ningún emparejamiento: es puramente cosmético, para que el shader generado se lea mejor. Esa es la diferencia entre una dependencia nominal, que un humano tiene que mantener sincronizada, y una dependencia referencial, que las herramientas mantienen por ti. Vale para los shaders y vale para cualquier frontera entre lenguajes: siempre que veas dos lados unidos por una cadena literal, estás mirando un bug futuro.