wandres.dev
TSL II · Nodos y composición

Control de flujo: If, Switch y Loop

Condicionales y bucles en TSL, la pila de instrucciones que los hace posibles, y por qué toVar deja de ser opcional en cuanto hay flujo de control.

⏱ 19 min

Hasta aquí todo el TSL que has escrito era una expresión: un árbol de operaciones sin sentencias, sin orden y sin estado. En cuanto aparece un condicional o un bucle eso cambia, porque hay cosas que ocurren antes que otras. TSL modela esa diferencia con una pila de instrucciones, y entender que existe es lo que explica por qué If solo funciona dentro de Fn y por qué las variables necesitan declararse.

🎯 Al terminar esta lección sabrás
  • Escribir condicionales con If, ElseIf y Else.
  • Usar Switch con casos múltiples y valor por defecto.
  • Escribir bucles contados, con condición y anidados, con Break y Continue.
  • Declarar variables mutables con toVar y asignarles dentro del flujo.

Variables, que ahora hacen falta

Una expresión no necesita variables porque su valor es el resultado del árbol. Una sentencia sí, porque el resultado depende de qué rama se ejecutó. Por eso todo el control de flujo de TSL empieza declarando una variable con .toVar():

import { Fn, float, vec3, If, positionLocal } from 'three/tsl';

const clasificar = Fn( () => {

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

  If( positionLocal.y.greaterThan( 0.5 ), () => {
    tono.assign( 1.0 );
  } ).Else( () => {
    tono.assign( 0.2 );
  } );

  return tono;

} );

.toVar() crea una variable mutable en el shader generado; .toConst() crea una constante de solo lectura. Los dos aceptan un nombre opcional, que aparece en el código emitido y hace mucho más legible la depuración:

const acumulado = vec3( 0 ).toVar( 'acumulado' );

Y ojo con un error explícito: .toVar() sobre un nodo de tipo void lanza TSL: ".toVar()" can not be used with void type.

Condicionales

If, ElseIf, Else

La forma completa se encadena:

If( condicion1, () => {

  // ...

} ).ElseIf( condicion2, () => {

  // ...

} ).Else( () => {

  // ...

} );

If es una función exportada de three/tsl. ElseIf y Else no lo son: son métodos del objeto que devuelve If, así que solo existen encadenados. Importar Else de three/tsl no funciona.

Un detalle que hay que asimilar pronto: el condicional es una sentencia, no una expresión. No devuelve nada útil, así que el patrón es siempre declarar la variable antes y asignarle dentro de las ramas.

Para el caso simple de elegir entre dos valores, hay algo mucho mejor que un If:

// Un If de dos ramas que solo asigna: usa una mezcla booleana.
const c = condicion.mix( color( 0xff0000 ), color( 0x0000ff ) );

Recuerda del primer tema de este nivel que el receptor de .mix() es el factor: aquí la condición booleana hace de selector. Genera una interpolación en lugar de una ramificación, y en una GPU eso es casi siempre más rápido, porque evita la divergencia.

Switch, Case y Default

import { Switch, uniform, int } from 'three/tsl';

const uModo = uniform( 0, 'int' );

const salida = vec3( 0 ).toVar();

Switch( uModo )
  .Case( 0, () => { salida.assign( colorBase ); } )
  .Case( 1, 2, 3, () => { salida.assign( colorBase.mul( 0.5 ) ); } )
  .Default( () => { salida.assign( vec3( 1, 0, 1 ) ); } );

Case acepta varios valores antes del callback, que quedan combinados con un o lógico. Necesita al menos dos parámetros; con menos, TSL avisa con TSL: Invalid parameter length. Case() requires at least two parameters. Como con If, Case y Default son métodos del objeto que devuelve Switch, no exportaciones.

Loop

Loop acepta varias formas, y las cuatro que importan son estas.

Contado. El caso normal. El callback recibe un objeto con el índice, llamado i en el primer nivel:

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

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

Loop( 8, ( { i } ) => {
  suma.addAssign( float( i ).mul( 0.125 ) );
} );

Configurado. Con un objeto se controlan inicio, fin, tipo y operador de comparación:

Loop( { start: 2, end: 10, type: 'int', condition: '<' }, ( { i } ) => {
  // ...
} );

También admite name para el nombre de la variable de índice y update para el paso.

Anidado en forma compacta. Varios contadores en una sola llamada, con índices i, j, k según profundidad:

Loop( 4, 4, ( { i, j } ) => {
  // Bucle doble: 16 iteraciones.
} );

Con condición. Pasando un nodo booleano en lugar de un contador, se obtiene el equivalente de un while:

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

Loop( t.lessThan( 10 ), () => {
  t.addAssign( 1 );
} );

Y para salir o saltar, Break() y Continue(), que son funciones y hay que invocarlas:

import { Loop, Break, If } from 'three/tsl';

Loop( 64, ( { i } ) => {

  If( distancia.lessThan( 0.001 ), () => {
    Break();
  } );

  distancia.assign( mapa( punto ) );

} );
🛑
Break() y Continue() sin paréntesis no hacen nada

Son funciones, no palabras clave. Escribir Break; dentro del callback es una expresión válida de JavaScript que no produce ningún efecto: el bucle sigue, el shader compila, y el bug es de los que cuesta ver porque el código parece correcto a primera vista. El mismo cuidado vale para Continue.

Todo junto

Por qué hace falta estar dentro de Fn

Todo el control de flujo se apoya en una pila que TSL mantiene mientras construye. Cuando escribes tono.assign( 1.0 ), esa asignación no devuelve un valor que alguien vaya a usar: es una sentencia que hay que anotar en algún sitio, en el orden correcto. Ese sitio es la pila, y solo hay pila dentro de la ejecución de un Fn.

Fuera de ella, Three.js emite el error que ya viste:

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

Que es, por cierto, un mensaje excelente: te dice exactamente qué hacer.

Un raymarcher en TSL

Uniendo el nivel treinta y seis con este, el bucle de marcha se traduce casi línea a línea:

import { Fn, float, vec3, Loop, Break, If, length } from 'three/tsl';

const sdEsfera = Fn( ( [ p, r ] ) => length( p ).sub( r ), {
  return: 'float', p: 'vec3', r: 'float'
} );

const marchar = Fn( ( [ ro, rd ] ) => {

  const t = float( 0 ).toVar( 't' );

  Loop( 96, () => {

    const p = ro.add( rd.mul( t ) );
    const d = sdEsfera( p, 1.0 ).toVar( 'd' );

    If( d.lessThan( t.mul( 0.001 ) ), () => { Break(); } );
    If( t.greaterThan( 50.0 ), () => { Break(); } );

    t.addAssign( d );

  } );

  return t;

}, { return: 'float', ro: 'vec3', rd: 'vec3' } );

Fíjate en .toVar( 'd' ) sobre el resultado de la SDF. Sin él, el nodo d se usa tres veces —en las dos comparaciones y en la suma— y la fase de análisis podría decidir duplicar la evaluación de la SDF. Con él, se evalúa una vez y se guarda. En un bucle de noventa y seis iteraciones esa diferencia es el triple del coste del shader entero.

La divergencia sigue siendo el enemigo, y TSL no la esconde

Es tentador pensar que como TSL parece JavaScript, escribir un If cuesta lo que cuesta un if en JavaScript. No es así, y el modelo mental correcto no ha cambiado desde el primer nivel de shaders: la GPU ejecuta los fragmentos en grupos que comparten el contador de programa. Cuando los fragmentos de un grupo toman ramas distintas de un condicional, el hardware no puede ejecutar dos caminos a la vez, así que ejecuta los dos y descarta el resultado del que no correspondía en cada carril. Un If con dos ramas caras cuesta la suma de las dos, no la más cara y desde luego no una sola. Lo mismo con un bucle de longitud variable: el grupo entero avanza al ritmo de su carril más lento, y un solo fragmento que necesite noventa y seis iteraciones obliga a sus treinta y un vecinos a esperar aunque hubieran acabado en cuatro. De ahí salen tres reglas operativas que valen igual en GLSL y en TSL. Prefiere una mezcla a un condicional cuando las dos ramas son baratas: condicion.mix(a, b) no diverge, y en la mayoría de los casos gana incluso cuando calcula las dos cosas. Si tienes que ramificar, ramifica por algo que sea coherente entre píxeles vecinos —una propiedad del material, un uniforme, un modo global— y no por algo que cambie píxel a píxel, como el resultado de un ruido. Y si tienes un bucle con salida temprana, acepta que su coste es el del peor píxel de cada grupo, no el medio, que es exactamente lo que hace que los raymarchers cuesten lo que cuestan. Lo que TSL sí te da aquí es una ventaja de otra clase: como el shader se construye en JavaScript, una ramificación que dependa de un valor conocido en tiempo de construcción se puede resolver con un if normal de JavaScript, eligiendo qué subgrafo montar, y entonces no llega ningún condicional al shader. Ese es el reemplazo de los #ifdef y los defines del sistema clásico, y es más limpio: en lugar de un preprocesador aparte, es el mismo lenguaje.