What TSL is
TSL, Three.js Shading Language, is a way of writing shaders in JavaScript. Instead of handing Three.js a string of GLSL or WGSL, you build the shader out of function calls: vec3(uv(), 0.0), sin(time.mul(2.0)), mix(a, b, t). Three compiles that node graph to WGSL when it's running on WebGPU and to GLSL when it falls back to WebGL. One shader, both backends. No string templating.
Every shader in Fragments is written in TSL, and I've taught a lot of people to write it over the past year. This is the path I'd give someone starting today, including the bits that trip people up.
Why it's worth learning at all
TSL will feel weird if you already write GLSL. Worth it anyway.
Reuse. A TSL function is a JavaScript function. You can import it, compose it, pass it around. The noise functions, SDF shapes and post-processing effects in Fragments are all just modules. In GLSL that reuse meant string concatenation and #include hacks.
Two backends for free. WebGPU where available, WebGL 2 where not. You don't maintain two shaders.
Compute shaders. TSL is the on-ramp to WebGPU compute in Three.js. Fn(...).compute(count). That's how you write particle systems, cellular automata and pixel sorting that were painful or impossible in WebGL.
It makes you better at GLSL and WGSL. This surprised me. Writing TSL means thinking about nodes and data flow, and I read raw shader code more fluently now than before.
The cost is a learning curve, even for people who already write shaders, and a smaller pool of tutorials than GLSL has. The official TSL guide has closed a lot of that gap in the last year; I lean on it below.
Step 0: have somewhere to type
Don't build an environment. Use one.
- The TSL guide. threejs.org/tsl is the reference: syntax, inputs, compute, post-processing, all with runnable examples.
- Three.js examples. The official TSL examples are runnable and you can read every one.
- A boilerplate. Fragments boilerplate for React Three Fiber, or the Vite walkthrough for vanilla. Either gets you a
WebGPURenderer, a camera and a full-screen plane, which is all a shader needs.
The plane-plus-fragment-shader setup is deliberate. Almost everything in Fragments is done on a single PlaneGeometry with a colorNode. Geometry comes later, if at all.
A lot of my own TSL time lately has gone into a creative coding tool I've been building for exactly this loop - type a Fn, see it, tweak it, chain it into something else - because I got tired of the setup tax every time I wanted to try an idea. It's not ready to talk about properly yet, but it's shaped everything in this post. More on that soon.
Step 1: rewrite something you already know
The fastest way in is to take a GLSL snippet you understand and translate it. UV gradient first:
void main() {
gl_FragColor = vec4(v_uv, 0.0, 1.0);
}import { MeshBasicNodeMaterial } from 'three/webgpu'
import { uv, vec3 } from 'three/tsl'
const material = new MeshBasicNodeMaterial()
material.colorNode = vec3(uv(), 0.0)Then something with time. There's no uniform to declare or update. time is a built-in node:
import { uv, vec3, sin, time } from 'three/tsl'
material.colorNode = vec3(uv(), sin(time).mul(0.5).add(0.5))The TSL primer has a longer run of these side-by-sides, including loops and conditionals.
If you have a pile of existing GLSL, you don't have to translate it by hand. glslFn from three/tsl wraps a GLSL function so it runs inside a TSL graph on the WebGL backend, and Three's transpiler converts GLSL to TSL or WGSL. Running a Shadertoy snippet through the transpiler and reading what comes out is a very fast way to learn the idioms.
Step 2: get used to the chaining
This is the habit that bites everyone. In GLSL you write a * b + c. In TSL, operators are methods:
// GLSL: float d = length(uv - 0.5) * 2.0 - 0.3;
const d = length(uv().sub(0.5)).mul(2.0).sub(0.3)A few rules that make this stop feeling weird:
- Every node has
.add,.sub,.mul,.div, plus comparisons like.greaterThanand swizzles like.xyor.r - Math functions are imported from
three/tsland take nodes or plain numbers.mix(a, b, 0.5)is fine - Reassignment is
.assign(), accumulation is.addAssign(). You'll use these insideFnbodies and compute shaders - Read chains left to right.
a.mul(b).add(c)is(a * b) + c
The guide's Method Chaining, Swizzle and Operators pages are the full list.
After a couple of hours this becomes automatic. After a week you'll stop translating from GLSL in your head.
Step 3: wrap things in Fn
Fn is how you make a reusable shader function (guide). It takes a JavaScript function and returns a node factory:
import { Fn, length, float } from 'three/tsl'
export const circle = Fn(([p, radius]) => {
return length(p).sub(radius)
})
// Later, anywhere:
const d = circle(uv().sub(0.5), float(0.3))Once you have a handful of these, you import them instead of rewriting them. The SDF Shapes and SDF Operations pages are essentially a library of Fns, and the geometric shapes technique, which is free, builds a full pattern out of them step by step. That's the lesson I'd do first.
Step 4: learn to read the output
When TSL does something you didn't expect, look at what it compiled to. Attach .debug() to any node and TSL prints the generated WGSL or GLSL for that expression, with the surrounding shader for context (guide). Reading it answers most "why is this black" questions quickly. The Debugging TSL page covers the workflow, and Runtime Tweaking shows how to hook uniform() nodes up to a control panel so you can poke values live instead of editing and reloading.
This is the step people skip and then get stuck. Ten minutes learning to inspect output saves hours.
Step 5: your first compute shader
Once fragment shaders feel comfortable, do one compute shader. It's the thing TSL makes easy that WebGL never did.
import { Fn, instancedArray, instanceIndex, hash } from 'three/tsl'
const count = 1024
const values = instancedArray(count, 'float')
const fill = Fn(() => {
values.element(instanceIndex).assign(hash(instanceIndex))
})().compute(count)
await renderer.computeAsync(fill)That's a GPU-side array filled with a thousand random numbers in parallel. From there, a flow field is a grid of angles, a particle buffer and two more Fns. The guide's Compute Stage and Storage pages cover workgroups and the buffer API when you need them.
Things that trip people up
- Mixing plain numbers and nodes. Fine as arguments, not as the receiver.
float(2.0).mul(x), not2.0.mul(x). - Forgetting
()onuv. It'suv(), a function that returns the node, not a constant. - Conditionals. Use
If(cond, () => {...})andselect(cond, a, b), not JavaScriptif. JSifruns once at graph-build time, not per pixel.Ifalso has to live inside aFnbody; it relies on the function's execution stack to build the branch. - Loops.
Loop(count, ({ i }) => {...}). Same reason. - Colour space.
WebGPURendereroutputs sRGB by default. If your colours look off compared to a Shadertoy reference, the Color Space Correction utility is usually the answer. - Precision of
hash/time.timegrows forever.fract(time)ortime.mod(...)when you need it bounded.
A rough order to work through
If you want a sequence rather than a pile of links:
- Shader Vocabulary for the words
- TSL primer for the syntax
- the free Geometric Shapes lesson for a complete build
- Procedural Color Palettes and Noise, because they're what make everything look good
- Flow Field for compute
Give it two weekends. The first one is awkward. The second one, you'll be reaching for TSL by default.