wandres.dev
TSL II · Nodos y composición

Operadores y encadenamiento

Las dos formas de escribir cada operación, la lista real de operadores de r184, las variantes de asignación, y la trampa de orden de argumentos que pilla a todo el mundo.

⏱ 18 min

Toda operación de TSL existe dos veces: como función suelta y como método sobre cualquier nodo. No es redundancia, es lo que permite escribir álgebra vectorial que se lee de izquierda a derecha en un lenguaje sin sobrecarga de operadores. La mecánica que lo hace posible tiene una consecuencia que hay que conocer, porque produce el error más silencioso de toda la API.

🎯 Al terminar esta lección sabrás
  • Escribir la misma expresión en forma funcional y encadenada.
  • Enumerar los operadores aritméticos, de comparación y de bits de r184.
  • Usar las variantes de asignación dentro de una función.
  • Predecir correctamente el orden de argumentos de mix, step y smoothstep encadenados.

Las dos formas

Three.js registra cada operación en el prototipo de Node. El mecanismo, literal:

Node.prototype[ name ] = function ( ...params ) {
  return this.isStackNode ? this.addToStack( nodeElement( ...params ) ) : nodeElement( this, ...params );
};

Léelo con calma porque es la clave de todo lo demás: el receptor del método se pasa como primer argumento a la función correspondiente. Por eso estas dos líneas construyen el mismo grafo:

import { add, mul, positionLocal } from 'three/tsl';

const a = mul( add( positionLocal.x, 1 ), 0.5 );
const b = positionLocal.x.add( 1 ).mul( 0.5 );

Elegir una u otra es cuestión de legibilidad. La forma encadenada gana cuando hay una cadena de transformaciones sobre un mismo valor; la funcional gana cuando la operación tiene varios argumentos igual de importantes, como mix o clamp, o cuando anidar métodos te obligaría a poner el argumento principal en el sitio equivocado.

Los operadores de r184

Todos existen como función y todos están registrados como método.

Categoría Nombres
Aritméticos add, sub, mul, div, mod
Comparación equal, notEqual, lessThan, greaterThan, lessThanEqual, greaterThanEqual
Lógicos and, or, not, xor
De bits bitAnd, bitOr, bitXor, bitNot, shiftLeft, shiftRight
Incremento increment, decrement, incrementBefore, decrementBefore

add, sub, mul, div, and y or aceptan un número variable de argumentos a partir de dos, así que mul( a, b, c, d ) es válido y produce un solo encadenamiento.

import { positionLocal, normalWorld, dot, vec3, and } from 'three/tsl';

const arriba = dot( normalWorld, vec3( 0, 1, 0 ) ).greaterThan( 0.7 );
const alto   = positionLocal.y.greaterThan( 0.5 );
const cima   = and( arriba, alto );

Un aviso: modInt() sigue exportándose pero está marcada como obsoleta desde r175 y avisa en consola. Su sustituto es mod( int( ... ) ).

Las variantes de asignación

Para cada operación registrada, Three.js instala además una variante con sufijo Assign:

Node.prototype[ name + 'Assign' ] = function ( ...params ) {
  return this.isStackNode ? this.assign( params[ 0 ], nodeElement( ...params ) ) : this.assign( nodeElement( this, ...params ) );
};

Eso te da .addAssign(), .subAssign(), .mulAssign(), .divAssign(), .modAssign(), .bitAndAssign(), .shiftLeftAssign() y el resto. Equivalen a los operadores compuestos de GLSL:

import { Fn, float, Loop } from 'three/tsl';

const acumular = Fn( ( [ n ] ) => {

  const total = float( 0 ).toVar();

  Loop( 8, ( { i } ) => {
    total.addAssign( n.mul( i ) );      // total += n * i
  } );

  return total;

} );

Dos condiciones para que esto funcione. La primera: el nodo destino tiene que ser una variable, creada con .toVar(); no se puede asignar a una expresión. La segunda: la asignación tiene que ocurrir dentro de un Fn(), porque el mecanismo necesita una pila de instrucciones donde apuntar la sentencia. Fuera de ella, Three.js emite este error literal:

TSL: No stack defined for assign operation. Make sure the assign is inside a Fn().

La trampa del orden de argumentos

Aquí está el detalle que pilla a todo el mundo, incluido quien lleva tiempo con TSL. Tres operaciones matemáticas de tres argumentos tienen un método encadenado cuyo orden no coincide con el de la función. La definición, literal de MathNode.js en r184:

export const mixElement        = ( t, e1, e2 )    => mix( e1, e2, t );
export const smoothstepElement = ( x, low, high ) => smoothstep( low, high, x );
export const stepElement       = ( x, edge )      => step( edge, x );

addMethodChaining( 'mix', mixElement );
addMethodChaining( 'smoothstep', smoothstepElement );
addMethodChaining( 'step', stepElement );

Combinado con la mecánica del encadenamiento, que pasa el receptor como primer argumento, el resultado es:

Encadenado Equivale a El receptor es
t.mix( a, b ) mix( a, b, t ) el factor de interpolación
x.smoothstep( lo, hi ) smoothstep( lo, hi, x ) el valor de entrada
x.step( borde ) step( borde, x ) el valor de entrada

Para smoothstep y step el diseño es natural: el receptor es el valor que se está transformando, y los argumentos son los bordes.

Para mix es contraintuitivo y es donde está el peligro. Quien viene de GLSL escribe colorA.mix( colorB, factor ) esperando mix( colorA, colorB, factor ), y obtiene mix( colorB, factor, colorA ). Como los tres son colores o vectores compatibles, no hay error de tipo: el shader compila y da un resultado incorrecto.

El uso correcto en la biblioteca de Three.js deja claro cuál es la intención. En ColorAdjustment.js:

export const saturation = /*@__PURE__*/ Fn( ( [ color, adjustment = float( 1 ) ] ) => {

  return adjustment.mix( luminance( color.rgb ), color.rgb );

} );

El receptor es adjustment, un escalar de ajuste, y los dos colores van como argumentos. Y en MaterialXNodes.js el patrón se usa como selector booleano:

export const mx_ifgreater = ( value1, value2, in1, in2 ) => value1.greaterThan( value2 ).mix( in1, in2 );

El receptor es una condición booleana que hace de factor. Leído así, a.mix( x, y ) significa “usa a para elegir entre x e y”, lo cual es coherente. Pero solo si lo sabes.

🛑
Usa la forma funcional para mix, step y smoothstep

Es la única recomendación categórica de todo este nivel. mix( a, b, t ), step( borde, x ) y smoothstep( lo, hi, x ) tienen el mismo orden que en GLSL, no admiten interpretación y no producen bugs silenciosos. Reserva la forma encadenada para operaciones cuyo orden sea evidente, como .mul(), .add(), .normalize() o .abs(). Y si te encuentras .mix() en código ajeno, lee dos veces qué es el receptor antes de asumir nada.

Los alias que no están encadenados

Un detalle menor con consecuencias prácticas: MathNode.js exporta dos alias en minúscula para compatibilidad con la ortografía de GLSL, faceforward e inversesqrt, pero solo registra como métodos las versiones en camelCase, faceForward e inverseSqrt. Es decir, x.inversesqrt() no existe aunque inversesqrt( x ) sí. Es exactamente el tipo de asimetría que cuesta diez minutos localizar.

El encadenamiento no es azúcar, es lo que hace legible el álgebra sin operadores

Vale la pena entender por qué los autores de TSL se tomaron la molestia de duplicar toda la API, porque la razón revela algo sobre cómo se lee el código de gráficos. Sin encadenamiento, una expresión de shader se escribe de dentro hacia fuera: mul( add( mul( x, a ), b ), c ). Para leerla hay que localizar primero el paréntesis más interno y desandar el camino, y el orden en que la lees es el inverso del orden en que ocurren las operaciones. Con operadores de verdad, como en GLSL, la precedencia recompone visualmente la secuencia y el problema desaparece. En JavaScript no hay operadores, así que si no hubiera encadenamiento toda expresión de más de dos niveles sería un ejercicio de contar paréntesis. El encadenamiento restaura el orden temporal: x.mul(a).add(b).mul(c) se lee exactamente en el orden en que se ejecuta, que es como piensas cuando escribes el cálculo. Ahora bien, esa misma virtud es la que explica la trampa de mix. El encadenamiento es un mecanismo genérico —“pon el receptor delante”— que asume que el receptor es el argumento principal, el que fluye por la cadena. Para .mul() y .abs() eso es obviamente cierto. Para mix, en cambio, ¿cuál es el argumento principal, el color de partida o el factor? Los autores decidieron que el factor, porque es lo que hace que condicion.mix(a, b) se lea como un selector y encaje con el flujo de datos que se estaba encadenando. Es una elección defendible y no está mal documentada, simplemente es distinta de la de GLSL. La lección transferible es que en cualquier API encadenada, la pregunta que hay que hacerse ante una operación de más de dos argumentos es cuál de ellos ocupa el lugar del receptor, y que asumir la respuesta por analogía con otra API es exactamente cómo se cuelan los bugs que no dan error.