Dos backends, dos lenguajes
Qué separa realmente a GLSL de WGSL, por qué mantener el mismo shader en ambos a mano es inviable, y qué problema concreto vino a resolver TSL.
Cuando WebGPU llegó, Three.js se encontró ante un dilema desagradable: mantener dos implementaciones completas del sistema de materiales, una en GLSL y otra en WGSL, o abandonar WebGL antes de que el ecosistema estuviera listo. Ninguna de las dos era aceptable. TSL es la tercera salida, y entender lo mala que era la alternativa es lo que explica por qué mereció la pena inventar un lenguaje entero.
- Enumerar las diferencias concretas entre GLSL ES 3.0 y WGSL que impiden traducir a ojo.
- Explicar por qué duplicar la biblioteca de shaders no era viable para Three.js.
- Situar TSL como respuesta a un problema de mantenimiento, no de sintaxis.
- Reconocer qué partes del problema TSL no resuelve.
Lo que separa a los dos lenguajes
GLSL ES 3.0 y WGSL hacen lo mismo y no se parecen en casi nada. Las diferencias no son cosméticas: son estructurales, y por eso una traducción automática ingenua no funciona.
La sintaxis viene de familias distintas. GLSL desciende de C. WGSL está inspirado en Rust: declaraciones con let y var, tipos después del nombre, funciones con fn y ->.
// GLSL ES 3.0
vec3 iluminar( vec3 n, vec3 l ) {
float d = max( dot( n, l ), 0.0 );
return vec3( d );
}
// WGSL
fn iluminar( n: vec3f, l: vec3f ) -> vec3f {
let d = max( dot( n, l ), 0.0 );
return vec3f( d );
}
El modelo de recursos es completamente distinto. GLSL tiene uniforms sueltos que se enlazan por nombre; WGSL tiene grupos de enlace y ubicaciones explícitas, con @group y @binding declarados en el shader y emparejados con un layout de pipeline declarado en JavaScript. No hay correspondencia uno a uno: un puñado de uniforms de GLSL se convierte en una estructura empaquetada según reglas de alineación estrictas.
Las texturas se separan del muestreador. En GLSL un sampler2D es una sola cosa. En WGSL son dos: texture_2d<f32> y sampler, declarados por separado y combinados en la llamada. Eso cambia la forma de todas las funciones que muestrean.
Las entradas y salidas se declaran de otra forma. GLSL usa variables globales in y out más gl_Position y gl_FragCoord. WGSL usa estructuras con atributos @location y @builtin, y la función devuelve su salida en lugar de escribir en globales.
Los sistemas de coordenadas no coinciden. Este es el que muerde en silencio. El rango de profundidad de recorte en WebGL va de menos uno a uno; en WebGPU va de cero a uno. Cualquier matemática que toque la profundidad da resultados distintos. Three.js lo modela explícitamente con las constantes WebGLCoordinateSystem y WebGPUCoordinateSystem, y hay código en el generador que ramifica según cuál esté activo.
Algunas funciones se llaman distinto. El caso más citado es la arcotangente de dos argumentos: en GLSL es atan( y, x ) sobrecargando la de un argumento, en WGSL es atan2( y, x ). El generador de nodos de Three.js lo traduce automáticamente, pero es exactamente la clase de diferencia que hace fracasar una traducción por sustitución de texto.
Por qué duplicar no era una opción
Imagina el trabajo real. La biblioteca de shaders de Three.js tiene más de un centenar de chunks y una docena larga de shaders completos, cubriendo materiales, sombras de varios tipos, niebla, morph targets, skinning, instancing, batching, planos de recorte, media docena de operadores de tone mapping y todo el modelo PBR con sus capas de clearcoat, sheen, iridescencia, anisotropía y transmisión.
Duplicar eso en WGSL significa mantener dos copias de cada uno de esos algoritmos. Y mantener dos copias no es escribirlas dos veces: es que cada corrección de un bug, cada nueva característica y cada ajuste de la ecuación de renderizado hay que hacerlo dos veces, en dos lenguajes, con dos conjuntos de pruebas, y verificando que las dos siguen produciendo la misma imagen. Para un proyecto sostenido en buena medida por voluntarios, eso es sencillamente inviable, y el resultado predecible sería que una de las dos copias se quedara atrás.
La segunda opción, abandonar WebGL, era peor por razones de fuera del proyecto. WebGPU no estuvo en los tres motores hasta que Safari 26 lo implementó en septiembre de 2025, y aun hoy la cobertura de dispositivos de WebGL2 sigue siendo mayor. Una librería cuyo valor es funcionar en todas partes no puede permitirse dejar fuera a una parte del parque.
La tercera opción es la que se tomó: describir los algoritmos una sola vez, en una representación independiente del lenguaje destino, y generar cada uno desde ahí. Los shaders del sistema de materiales de nodos están escritos en TSL, no en GLSL ni en WGSL, y las dos salidas se producen a partir de la misma fuente. La biblioteca se mantiene una vez.
Lo que TSL no resuelve
Conviene ser preciso sobre el alcance, porque es fácil sobrevender esto.
TSL abstrae el lenguaje de shading. No abstrae el modelo de ejecución de las dos APIs. Los compute shaders, los storage buffers, los grupos de trabajo y las barreras de sincronización existen en WebGPU y no en WebGL2, y ninguna generación de código los va a hacer aparecer. Un grafo TSL que use storage() y compute funciona sobre el backend de WebGPU y no tiene traducción al de WebGL.
Tampoco abstrae el rendimiento. El mismo grafo genera código para dos pipelines con características distintas, y el resultado puede tener un perfil de rendimiento distinto en cada uno. No es raro que un efecto vaya notablemente mejor en un backend que en el otro.
Y no elimina la necesidad de entender shaders. TSL cambia cómo los escribes, no qué son: sigues necesitando saber qué es un fragmento, por qué la ramificación es cara, qué significa una normal interpolada y por qué el orden de las operaciones importa. Todo lo que has aprendido escribiendo GLSL sigue aplicando; lo único que cambia es la notación y quién genera el texto final.
Hay un modelo mental erróneo muy extendido y vale la pena desactivarlo pronto: la idea de que TSL “toma tu shader y lo traduce” a los dos lenguajes. Si eso fuera lo que hace, sería un transpilador, y los transpiladores tienen una propiedad incómoda: para traducir un lenguaje hay que entenderlo entero, incluidos sus rincones raros, y el resultado casi siempre arrastra las peculiaridades del origen a un destino donde no encajan. Ese camino se ha intentado en la industria más de una vez y produce herramientas frágiles con listas largas de construcciones no soportadas. Lo que TSL hace es exactamente lo contrario y es mucho más astuto: nunca existe un shader de origen que traducir. No escribes GLSL para que alguien lo convierta; escribes directamente la representación intermedia, el grafo, que es lo que un compilador construiría después de parsear. Te has saltado la fase de análisis por completo. Y de esa decisión salen dos propiedades que un transpilador no puede tener. La primera es que no hay construcciones no soportadas: si puedes expresarlo con nodos, se genera para ambos destinos por construcción, porque el generador nunca se enfrenta a sintaxis sino a un árbol que él mismo define. La segunda, más interesante, es que el generador puede tomar decisiones distintas para cada destino con conocimiento completo de la intención: sabe que ese nodo es una arcotangente de dos argumentos, así que emite atan para uno y atan2 para el otro; sabe que ese nodo es una profundidad de recorte, así que emite el rango correcto en cada sistema de coordenadas; sabe que ese uniform pertenece a un grupo de actualización por objeto, así que lo empaqueta donde toca. Un traductor de texto tendría que inferir todo eso desde la sintaxis, y muchas veces no podría. La lección general vale más allá de los shaders: cuando tengas que soportar dos destinos incompatibles, casi siempre sale mejor subir un nivel de abstracción y generar los dos, que escribir uno y traducirlo al otro.