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.
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 ) );} );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.