I’ve been thinking about TSL for a long time on and off, and I want to like it. But I don’t.
And so, I wanted to put a few words on paper as to why, as I hope that this can serve to improve TSL. You can think of it as a review of the current state of TSL.
First off, what’s TSL? Three.js Shading Language, it’s something that @sunag was working on for a better part of a decade, it was originally referred to as “Node-based shaders”, it still very much is the same thing, but it has evolved to serve a new function, specifically - abstracting the underlying graphics API (WebGL / WebGPU).
Who the heck am I to talk about languages, graphics and node-based techniques?
-
Over the years I have shipped a number of node-based frameworks, there are a few in Meep even.
-
Similarly, I have designed a number of languages in my career, both textual and programmatic as well as various file formats. I am proficient in a number of very distinct programming languages such as C, C++, Java and C#, and can comfortably read many more. In the domain of graphics - I’m very familiar with different version of GLSL, HLSL, SPIR-V(not exactly a language), WGSL and Metal.
-
As for graphics, graphics has been my main area professionally for close to a decade now, and I have been working with graphics for much longer.
All of that is to give my arguments a bit of extra weight.
So, with the obligatory disclaimers out of the way.
What’s wrong with TSL?
A language needs to be helpful, first and foremost. We could just bang instructions directly into computer’s memory if that were not the case.
What does TSL accomplish, well it does 2 nice things:
- It abstracts the API (WebGPU vs WebGL)
- It allows code reuse
Code reuse is typically achieved with some form of modules in a language, such as the import statement in JavaScript. TSL achieves code reuse through composable nodes which can in turn be packaged into JS modules, et voilá - code reuse.
At this point, unfortunately, we have reached the end of the list of good things about TSL.
Allow me to make a small tangent. I have taught many junior to mid developers in the past, and it’s a common trait of ours, engineers that is, to want to abstract.
You start with a very simple task and you want to give your solution some structure, you want your solution to not only solve this one simple task, but to be able to solve other similar tasks. Why stop there? we can abstract the solution to solve tasks that are more and more different from the original goal. This is something I’m guilty of, for sure. I wrote an entire fully-featured game engine when was working on a specific game. It doesn’t get much closer than that to the definition of “Over-Engineered”.
And that’s what I see TSL as - “Over-Engineered”.
TSL is:
- poorly documented
- slow to compile
- slow to execute
- painful to debug
- feature-limiting
- awkward to write
- has a massive API surface
Let’s go 1 by 1
Poorly documented
But Alex, there is the documentation right there: three.js docs
Let’s take a look at this documentation. To avoid cherry-picking, I’ll go with the very first entry for TSL
TSL function for creating a
Break()expression.
Well - that’s clear as mud. And you may think:
You said no cherry-picking and yet you picked, you cherried! you dirty picker of cherries!
Sadly most of it is like that. And even if it were not the case - this is still a problem, here’s the entire list of entires in the documentation under TSL:
- Break
- Const
- Continue
- Discard
- EPSILON
- HALF_PI
- INFINITY
- If
- Loop
- PI
- PI2
- Return
- Switch
- TBNViewMatrix
- TWO_PI
- Var
- VarIntent
- abs
- acesFilmicToneMapping
- acos
- add
- afterImage
- agxToneMapping
- all
- alphaT
- anaglyphPass
- anamorphic
- and
- anisotropy
- anisotropyB
- anisotropyT
- any
- ao
- append
- array
- asin
- assign
- atan
- atan2
- atomicAdd
- atomicAnd
- atomicFunc
- atomicLoad
- atomicMax
- atomicMin
- atomicNode
- atomicOr
- atomicStore
- atomicSub
- atomicXor
- attenuationColor
- attenuationDistance
- attribute
- attributeArray
- backgroundBlurriness
- backgroundIntensity
- backgroundRotation
- barrier
- batch
- bentNormalView
- billboarding
- bitAnd
- bitNot
- bitOr
- bitXor
- bitangentGeometry
- bitangentLocal
- bitangentView
- bitangentViewFrame
- bitangentWorld
- bitcast
- bleach
- blendBurn
- blendColor
- blendDodge
- blendOverlay
- blendScreen
- bloom
- boxBlur
- buffer
- bufferAttribute
- builtin
- builtinAOContext
- builtinShadowContext
- bumpMap
- burn
- bypass
- cache
- cameraFar
- cameraIndex
- cameraNear
- cameraNormalMatrix
- cameraPosition
- cameraProjectionMatrix
- cameraProjectionMatrixInverse
- cameraViewMatrix
- cameraViewport
- cameraWorldMatrix
- cbrt
- cdl
- ceil
- checker
- chromaticAberration
- cineonToneMapping
- circleIntersectsAABB
- clamp
- clearcoat
- clearcoatNormalView
- clearcoatRoughness
- clipping
- clippingAlpha
- code
- colorSpaceToWorking
- colorToDirection
- compute
- computeBuiltin
- computeKernel
- computeSkinning
- context
- convertColorSpace
- convertToTexture
- cos
- countLeadingZeros
- countOneBits
- countTrailingZeros
- createVar
- cross
- cubeMapNode
- cubeTexture
- cubeTextureBase
- dFdx
- dFdy
- dashSize
- debug
- decrement
- decrementBefore
- degrees
- deltaTime
- denoise
- densityFog
- densityFogFactor
- depth
- depthBase
- depthPass
- determinant
- difference
- diffuseColor
- diffuseContribution
- directionToColor
- directionToFaceDirection
- dispersion
- distance
- div
- dodge
- dof
- dot
- dotScreen
- drawIndex
- dynamicBufferAttribute
- emissive
- equal
- equals
- equirectUV
- exp
- exp2
- expression
- faceDirection
- faceForward
- film
- floatBitsToInt
- floatBitsToUint
- floor
- fog
- fract
- frameGroup
- frameId
- frontFacing
- fwidth
- fxaa
- gain
- gapSize
- gaussianBlur
- getNormalFromDepth
- getParallaxCorrectNormal
- getScreenPosition
- getShadowMaterial
- getShadowRenderObjectFunction
- getViewPosition
- globalId
- glsl
- grayscale
- greaterThan
- greaterThanEqual
- hardwareClipping
- hash
- hashBlur
- highpModelNormalViewMatrix
- highpModelViewMatrix
- hue
- increment
- incrementBefore
- inspector
- instance
- instanceIndex
- instancedArray
- instancedBufferAttribute
- instancedDynamicBufferAttribute
- instancedMesh
- intBitsToFloat
- interleavedGradientNoise
- inverse
- inverseSqrt
- invocationLocalIndex
- invocationSubgroupIndex
- ior
- iridescence
- iridescenceIOR
- iridescenceThickness
- isolate
- js
- label
- length
- lengthSq
- lensflare
- lessThan
- lessThanEqual
- lightPosition
- lightProjectionUV
- lightShadowMatrix
- lightTargetDirection
- lightTargetPosition
- lightViewPosition
- lights
- linearDepth
- linearToneMapping
- localId
- log
- log2
- logarithmicDepthToViewZ
- luminance
- lut3D
- matcapUV
- materialAO
- materialAlphaTest
- materialAnisotropy
- materialAnisotropyVector
- materialAttenuationColor
- materialAttenuationDistance
- materialClearcoat
- materialClearcoatNormal
- materialClearcoatRoughness
- materialColor
- materialDispersion
- materialEmissive
- materialEnvIntensity
- materialEnvRotation
- materialIOR
- materialIridescence
- materialIridescenceIOR
- materialIridescenceThickness
- materialLightMap
- materialLineDashOffset
- materialLineDashSize
- materialLineGapSize
- materialLineScale
- materialLineWidth
- materialMetalness
- materialNormal
- materialOpacity
- materialPointSize
- materialReference
- materialReflectivity
- materialRefractionRatio
- materialRotation
- materialRoughness
- materialSheen
- materialSheenRoughness
- materialShininess
- materialSpecular
- materialSpecularColor
- materialSpecularIntensity
- materialSpecularStrength
- materialThickness
- materialTransmission
- max
- maxMipLevel
- mediumpModelViewMatrix
- metalness
- min
- mix
- mixElement
- mod
- modInt
- modelDirection
- modelNormalMatrix
- modelPosition
- modelRadius
- modelScale
- modelViewMatrix
- modelViewPosition
- modelViewProjection
- modelWorldMatrix
- modelWorldMatrixInverse
- morphReference
- motionBlur
- mrt
- mul
- negate
- neutralToneMapping
- normalFlat
- normalGeometry
- normalLocal
- normalMap
- normalView
- normalViewGeometry
- normalWorld
- normalWorldGeometry
- normalize
- not
- notEqual
- numWorkgroups
- objectDirection
- objectGroup
- objectPosition
- objectRadius
- objectScale
- objectViewPosition
- objectWorldMatrix
- oneMinus
- or
- orthographicDepthToViewZ
- oscSawtooth
- oscSine
- oscSquare
- oscTriangle
- outline
- output
- outputStruct
- overlay
- overloadingFn
- packHalf2x16
- packSnorm2x16
- packUnorm2x16
- parabola
- parallaxBarrierPass
- parallaxDirection
- parallaxUV
- parameter
- pass
- passTexture
- pcurve
- perspectiveDepthToViewZ
- pixelationPass
- pmremTexture
- pointShadow
- pointUV
- pointWidth
- positionGeometry
- positionLocal
- positionPrevious
- positionView
- positionViewDirection
- positionWorld
- positionWorldDirection
- posterize
- pow
- pow2
- pow3
- pow4
- premultipliedGaussianBlur
- premultiplyAlpha
- property
- quadBroadcast
- quadSwapDiagonal
- quadSwapX
- quadSwapY
- radialBlur
- radians
- rand
- range
- rangeFog
- rangeFogFactor
- reciprocal
- reference
- referenceBuffer
- reflect
- reflectVector
- reflectView
- reflector
- refract
- refractVector
- refractView
- reinhardToneMapping
- remap
- remapClamp
- renderGroup
- renderOutput
- rendererReference
- replaceDefaultUV
- rgbShift
- rotate
- rotateUV
- roughness
- round
- rtt
- sRGBTransferEOTF
- sRGBTransferOETF
- sampler
- samplerComparison
- saturate
- saturation
- screen
- screenCoordinate
- screenDPR
- screenSize
- screenUV
- scriptable
- scriptableValue
- select
- sepia
- setName
- shadow
- shadowPositionWorld
- shapeCircle
- sharedUniformGroup
- sheen
- sheenRoughness
- shiftLeft
- shiftRight
- shininess
- sign
- sin
- sinc
- skinning
- smaa
- smoothstep
- smoothstepElement
- sobel
- specularColor
- specularColorBlended
- specularF90
- spherizeUV
- spritesheetUV
- sqrt
- ssaaPass
- ssgi
- ssr
- sss
- stack
- step
- stepElement
- stereoPass
- storage
- storageBarrier
- storageElement
- storageObject
- storageTexture
- struct
- sub
- subBuild
- subgroupAdd
- subgroupAll
- subgroupAnd
- subgroupAny
- subgroupBallot
- subgroupBroadcast
- subgroupBroadcastFirst
- subgroupElect
- subgroupExclusiveAdd
- subgroupExclusiveMul
- subgroupInclusiveAdd
- subgroupInclusiveMul
- subgroupIndex
- subgroupMax
- subgroupMin
- subgroupMul
- subgroupOr
- subgroupShuffle
- subgroupShuffleDown
- subgroupShuffleUp
- subgroupShuffleXor
- subgroupSize
- subgroupXor
- tan
- tangentGeometry
- tangentLocal
- tangentView
- tangentViewFrame
- tangentWorld
- texture
- texture3D
- texture3DLevel
- texture3DLoad
- textureBarrier
- textureBase
- textureBicubic
- textureBicubicLevel
- textureLoad
- textureSize
- textureStore
- thickness
- tiledLights
- time
- toneMapping
- toneMappingExposure
- toonOutlinePass
- traa
- transformDirection
- transformNormal
- transformNormalToView
- transformedClearcoatNormalView
- transformedNormalView
- transformedNormalWorld
- transition
- transmission
- transpose
- triNoise3D
- triplanarTexture
- triplanarTextures
- trunc
- uintBitsToFloat
- uniform
- uniformArray
- uniformCubeTexture
- uniformFlow
- uniformGroup
- uniformTexture
- unpackHalf2x16
- unpackNormal
- unpackSnorm2x16
- unpackUnorm2x16
- unpremultiplyAlpha
- userData
- uv
- varying
- varyingProperty
- velocity
- vertexColor
- vertexIndex
- vertexStage
- vibrance
- viewZToLogarithmicDepth
- viewZToOrthographicDepth
- viewZToPerspectiveDepth
- viewport
- viewportCoordinate
- viewportDepthTexture
- viewportLinearDepth
- viewportMipTexture
- viewportSafeUV
- viewportSharedTexture
- viewportSize
- viewportTexture
- viewportUV
- vogelDiskSample
- wgsl
- workgroupArray
- workgroupBarrier
- workgroupId
- workingToColorSpace
- xor
It’s 561 entries. Let that sink in. The entire WGSL spec has only about 150 operations. Yes, you could say
But Alex, WGSL has a lot more than that, you have attributes and generic types and compute shaders and WebGPU stuff like pipelines and bind groups oh my!
And you’d be totally right, but… TSL has the same things. So the comparison is pretty fair, sadly.
Let’s acknowledge the fact that not all of what’s documented on three.js docs is “strictly” TSL, a lot of it are things built from basic building blocks, like triplanarTexture, to pick a random example. But that’s also a point against the documentation - it doesn’t make that distinction.
It is my belief that TSL can not succeed until it can be learned and understood, neither the documentation in code, nor the error messages from the compiler, nor the official documentation on the website are particularly helpful with that currently.
Slow to compile
Let’s get the obvious out of the way. TSL can not be faster than WGSL or GLSL when it comes to compilation. TSL is an abstraction on top of these, so there will always be WGSL/GLSL emitted from TSL, and that will take time to compile. So TSL is already in a losing position from the start.
But it gets much worse. I don’t know the current state of TSL’s code, but when I last reviewed it a few years ago - it wasn’t a well written compiler. When I say this - I say it as someone who has written a their fair share of compilers and someone who is intimately familiar with a number of large mainstream compilers.
Somewhat anecdotally - I tried TSL some number of years ago, and I remember being amazed by the fact that a Node-based standard PBR material was taking over a second to compile to GLSL on a top-of-the-line desktop CPU. Recently I had the pleasure of working with TSL once again, and I can confirm that it still takes a long time
And this is before we get to compiling the emitted WGSL. Now imagine you have 10 materials instead of just the one. And imagine you trigger a material re-compilation or a new object appears in view that didn’t have a material compiled yet. Unreal engine recently has been in hot water for “stutter struggle”, but TSL is that on steroids.
I don’t mean to diminish @sunag 's work. I’m guessing he’s not a compiler engineer by trade, and TSL doesn’t look like it was designed for fast compile times to begin with. And yet… here we are…
Slow to execute
A compiler is a complex system. Compiling the code is actually the easy part. The hard part is the optimization and analysis.
TSL, appears to be a simple substitution-based compiler. This is pretty much the most basic you can get. And nothing wrong with that honestly, I’ve written a bunch of these and they are popular for a reason. However, when you’re want to produce optimal code as a result - it’s nowhere near enough.
You need a good AST/CST to perform analysis, and you need a robust tree transformation system in place to implement various optimization rules.
As it stands, here’s the result of using depth node:
( ( ( render.cameraNear + v_positionView.z ) * render.cameraFar ) / ( ( render.cameraFar - render.cameraNear ) * v_positionView.z ) )
And here it is used twice
( ( ( render.cameraNear + v_positionView.z ) * render.cameraFar ) / ( ( render.cameraFar - render.cameraNear ) * v_positionView.z ) )
( ( ( render.cameraNear + v_positionView.z ) * render.cameraFar ) / ( ( render.cameraFar - render.cameraNear ) * v_positionView.z ) )
There is no common-expression substitution. Over the course of a typical material shader this will be a death by a thousand wasted ALU operations. You get the picture.
But Alex, this optimization whatcha-ma-call-it-thing you described, we can haz it!
That is a very well thought out argument, and I agree. We can indeed haz it. But it would likely take significant engineering effort to allow for such optimizations in the first place, and then… well, there’s a reason V8 (Chrome’s JS runtime) doesn’t optimize code immediately, there’s a reason it does JIT and there’s a reason why optimizations are progressive. Optimizing code is slow. In fact, the better optimization techniques are the slowest ones.
Heck, consider why WGSL takes a long time to compile, it’s not the translation to SPIR-V, and it’s not GPU driver converting the SPIR-V to instructions. It’s the optimization that the driver does under the hood. But let’s not get into all that, let’s stay on topic.
To get well-performing WGSL out of TSL - you need optimizations to be done as part of the compilation process. Let’s agree on that. And optimizations are slow as rule, in every compiler. I’m not singling TSL out here.
Therefore - TSL is slow to execute, and slow to compile. And even if we make it faster to execute - it will still be slow to compile. For now we have worst of both worlds.
Painful to debug
When something goes wrong, TSL doesn’t notice half the time. It happily emits WGSL, which doesn’t compile.
Now you’re in a situation where you have a bunch of WGSL code in front of you, that doesn’t look remotely similar to what you were writing in TSL, and the WGSL compiler is complaining at you about concepts you haven’t been operating with.
Is this hypothetical?
- No, it happened to me last week.
Let’s consider another situation - TSL compiler does detect that you did an oopsie, and it blows up. The error message is not helpful. You are not told “where” you made a mistake, you are not told what was the causal chain that led to this point, you are just told something like “value is not a texture”.
… very helpful TSL… thank you.
If you try to set a breakpoint on uncaught exceptions - it becomes marginally more helpful, as you can use the debugger’s execution stack and evaluation context to figure out where you are in the node tree, but this is far from acceptable. If you believe it is acceptable - I don’t know what to tell you… you deserve better..?
Feature-limiting
Let’s get back to that depth example from earlier. This is not true depth value that comes as a fragment shader’s input. And if you use screenCoord node - you get the .xy, but no z (depth).
You can’t use pointers in WGSL via TSL.
You can’t disable uniformity checks in WGLS, which makes many algorithms impossible to implement.
You can’t use workgroup storage space.
These are just a few, and there can be an argument made
Man, TSL is, like, it can do anything… man
You can indeed write a piece of WGSL code and ask TSL to wrap it for you. But at that point TSL becomes an obstacle, as I can write WGSL just fine without it. And by using this technique - one of the benefits of TSL, the cross-compatibility with WebGL - it disappears.
WGSL may seems like the manna from the gods, but the truth is - it’s already a limited API, WGSL doesn’t expose access to nearly as many GPU features as something like HLSL or Vulkan. You’re already limited before you get to TSL, what values is being traded in return for these extra limitations?
And you might say
Well, Alex, akshually… these limitations will disappear in time, and you’re just being unfair and picky in a very cherry way
But as someone who has worked on cross-compilers, I can say that sadly, this is more of a law than a suggestion. If you have 3 languages:
- A
- B
- C
And C needs to translate into A and B, C can not be any more expressive than the common shared expressiveness of A and B.
Now, this diagram is unfair, because the commonality between something like GLSL and WGSL is massive, and there’s probably something like 20% of WGSL that’s outside of the GLSL and something like 5% or so of GLSL that’s outside of WGSL, but the point stands.
It’s a law. You can’t fight it, you can try and you might get around one or two limitations in a sort-of okay way, but it’s an uphill struggle that usually isn’t worth the trouble. Your language would need to invent more abstract and complex concepts for this to be possible, and let me tell you - designers of WGSL are not fools, you’re unlikely to beat them at their own game.
Awkward to write
Suppose you want to write an If statement, here’s what you have to do:
import { If } from 'three/tsl'; // 1. import the function
...
If( some_node, () => {
// then clause
}, ()=>{
// else clause
});
This is awkward. It’s awkward not only because you need import every keyword you use, but also because the syntax is a lot more convoluted. Let’s compare it to JS if statement as a lexer would:
if( some_condition ){
}else{
}
For JS we have 9:
IF
LPAREN
IDENTIFIER // "some_condition"
RPAREN
LBRACE
RBRACE
ELSE
LBRACE
RBRACE
For TSL we have 16:
IF
LPAREN
IDENTIFIER // "some_node"
COMMA
LPAREN
RPAREN
ARROW
LBRACE
RBRACE
COMMA
LPAREN
RPAREN
ARROW
LBRACE
RBRACE
RPAREN
And you might say
But alex, they look about the same
And to that I’d say, first of all - I resent the lower-case in my name, and second: it’s the congnitive load. We write the code not exactly in the same way a lexer parses it, but we do break it down into logical pieces, tokens, if you will. And those extra tokens absolutely cost you extra WPU cycles (Wet Processing Unit ™)
The functional notation on operations is also cumbersome. Instead of saying a + b you have + a b essentially. And if you’re a fan of Scheme - you ain’t a friend of mine. Maybe that’s why I don’t have friends. You all just love Scheme so much ![]()
Has a massive API surface
It was just node bro…
The intention was good. And it always is I think.
Initially, if you wnated to have NodeMaterial you would have 2 nodes:
- one for vertex shader
- one for fragment shader
But Now we have:
- .fragmentNode : Node.
- .vertexNode : Node.
…
You thought I was going to stop there? ![]()
You also have
- .alphaTestNode : Node.
- .aoNode : Node.
- .backdropAlphaNode : Node.
- .backdropNode : Node.
- .castShadowNode : Node.
- .castShadowPositionNode : Node.
- .colorNode : Node.
- .contextNode : ContextNode
- .depthNode : Node.
- .envNode : Node.
- .geometryNode : function
- .lightsNode : LightsNode
- .maskNode : Node.
- .mrtNode : MRTNode
- .normalNode : Node.
- .opacityNode : Node.
- .outputNode : Node.
- .positionNode : Node.
- .receivedShadowNode : function | FunctionNode.
- .receivedShadowPositionNode : Node.
And that’s just he case for now. Now let’s consider what a shader looks like in WebGPU:
- You have the bind group layout (your uniforms/terxtures/buffers etc)
- You have the vertex shader
- You have the fragment shader
That’s it.
What now?
I think TSL is a bad language by itself, that much is probably clear. However, it does have something going for it that can make it useful.
Here are a few things I believe can make it so:
- Offline compilation. Have your TSL shaders, compose them, re-use code, compile them to WGSL or GLSL with maximum level of optimization, but ship the WGSL/GLSL. Keep TSL offline so you don’t force the users to pay the compilation cost and damage the experience with stutter.
- Visual tooling. TSL is node-based. Have a fully-featured node-based graphical shader editor would make it worth the trouble. So far we don’t have that. Oh, sure, there is the prototype editor somewhere, and there are toys here and there, but nothing solid, nothing that you could use end-to-end to build shaders seriously.
- Emulation. This would single-handedly make TSL one of the best languages for graphics debugging. You could run the TSL on the CPU without translation to WGSL/GLSL, and this would enable you to step through the code, inspect variables and have the best-in-class debugging experience.
If not TSL then what?
I said so many mean things about TSL, but what is the alternative?
Well, for compatibility’s sake - you could use HLSL as a source language, or you could use WGSL and translate that GLSL. You could use SPIR-V, you could use Slang (from Nvidia). All of those languages (except for SPIR-V) were actually designed to be written by humans.
As for node-based, I think you’d be better served by having a node-based WGSL/GLSL targeting compiler, instead of a compiler that targets materials or specific shaders. Yes, there would be a need for an extra layer to glue the two halves together, but it would be additive, not either-or, as is the case today.
Disclaimer 2.0
This is not meant as an attack on three.js, or any of the three.js developers. I love @sunag, even if he already has someone
and I have an immense respect for the work that went into TSL.


