Command Palette

Search for a command to run...

Hectal
Course outline
Topic 13.3Advanced ~22 min · step 3 of 6

TSL Basics

In one line

TSL, the Three.js Shading Language, builds shader logic from JavaScript functions: Fn, uniform, time, uv, mix, sin. One program becomes WGSL on WebGPU and GLSL on WebGL 2. Its documentation is thin, so you learn to read the source.

0/6 · 0%

Think of it like this

Sheet music is written once, in notes, and any musician plays it on their own instrument: a violin or a piano. TSL is the sheet music for shaders. You write the effect once, in JavaScript, and three.js plays it on the instrument that is there: WGSL for WebGPU, GLSL for WebGL 2.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

TSL
Three.js Shading Language: shader logic written as JavaScript calls that build node graphs.
WGSL
WebGPU Shading Language: the shader language of WebGPU.
GLSL
OpenGL Shading Language: the shader language of WebGL 2 (Topic 12.1).
Fn
A TSL helper that wraps a function so it becomes a reusable piece of shader.
swizzle
Picking or reordering the parts of a vector by letters: p.xxy makes a new vector from p's x, x and y.
linear light
The colour space the shader does its maths in. Hex colours are converted into it, then converted back for the screen.

Step by step

01One line of TSL

mix(color("#2a9df4"), color("#ff6b2c"), step(0.5, fract(uv().x.mul(8)))) reads from the inside out: take the horizontal uv, make it run 0 to 8, keep the fractional part (so 0 to 1 eight times), turn it into 0 or 1 at the half, and use that to choose blue or orange. Eight stripes.

stripes.jsadd to filejs
mix(
  color("#2a9df4"),
  color("#ff6b2c"),
  step(0.5, fract(uv().x.mul(8))),
)

02Read what it became

The first example asks the renderer for the shader it generated. The line printed on WebGPU and on WebGL 2 is the same maths, in each language's spelling. See the two code blocks below it.

03Reuse with Fn and change with uniform

The second example builds pulse once and uses it twice, for brightness and for size. speed is a uniform, so one assignment changes the animation. It also checks prefers-reduced-motion and sets the speed to 0 for visitors who asked for less movement.

04Find a function in the source

When the guide has no page for a function, search the source. The terminal below finds where smoothstep is defined and shows that its doc comment lists its three parameters, in the order low, high, x.

terminal
$ grep -rn "export const smoothstep" node_modules/three/src/nodes
── expected output ──
node_modules/three/src/nodes/math/MathNode.js:1093:export const smoothstep = /*@__PURE__*/ nodeProxyIntent( MathNode, MathNode.SMOOTHSTEP ).setParameterLength( 3 );
node_modules/three/src/nodes/math/MathNode.js:1146:export const smoothstepElement = ( x, low, high ) => smoothstep( low, high, x );
Run in your lumen-3d project after `npm install three@0.186.1`. `Three.TSL.js` has 682 `export const` lines in this version.

Try it yourself

  1. 1

    Turn the stripes

    In the first example change uv().x to uv().y. The stripes become horizontal. Change the 8 to 3 and there are three. The printed line changes its 8.0 to 3.0, and nodeVarying4.x to .y.

  2. 2

    Stand still

    In the pulse example add speed.value = 0; after the uniform line. The ball stops: sin(0) is 0, so the pulse is exactly 1 and the colour and size stay at their base values. This is what the reduced-motion branch does.

Code & diagrams

One line of TSL, and the shader it becomes r186+ New tab

Prints WGSL on WebGPU and GLSL on WebGL 2, so no `output` is set. The stripes are drawn either way.

idle

Console

Run the scene to see its console.
The generated line, in both languageswgsl

Same maths. WGSL spells vector types `vec3<f32>`, GLSL spells them `vec3`. The colours are already in linear light.

// WebGPU (WGSL), from the example above:
DiffuseColor = vec4<f32>( mix( vec3<f32>( 0.023153366173251363, 0.3371636150376657, 0.9046611743890203 ), vec3<f32>( 1.0, 0.14702726648767014, 0.025186859622305935 ), step( 0.5, fract( ( nodeVarying4.x * 8.0 ) ) ) ), 1.0 );

// WebGL 2 (GLSL), same example:
DiffuseColor = vec4( mix( vec3( 0.023153366173251363, 0.3371636150376657, 0.9046611743890203 ), vec3( 1.0, 0.14702726648767014, 0.025186859622305935 ), step( 0.5, fract( ( nodeVarying4.x * 8.0 ) ) ) ), 1.0 );
Fn, uniform, time and reduced motion New tab

The ball pulses in brightness and breathes in size. In a browser set to reduce motion, `speed` is 0 and it stands still. No `output` because of the fallback warning.

idle

Console

Run the scene to see its console.
Two JavaScript habits that bite New tab

`forceWebGL` keeps the output the same everywhere. The last line is a statement of what the picture shows: the left half is blue, the right half orange.

idle

Console

Run the scene to see its console.

Expected console output

JS  a + b:    [object Object][object Object]
TSL a.add(b): a node: a recipe, not a number
JS  if (node): branch taken, whatever the GPU will compute
drawn: blue on the left half, orange on the right

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Use Math.sin on a node

In the pulse example change sin(time.mul(speed)) to Math.sin(time.mul(speed)).

terminal
$ Run
── what you'll see ──
[error] THREE.TSL: TypeError: Math.sin(...).mul is not a function "about:srcdoc:..."
(then a stack trace) and the ball is drawn flat black

Break #2

Leave out an argument

In the pulse example change pulse(speed, 0.08) to pulse(speed).

terminal
$ Run
── what you'll see ──
WebGPU:
[error] THREE.WebGPURenderer: Uncaptured WebGPU GPUValidationError: Error while parsing WGSL: ... unresolved value 'null'
WebGL 2:
[error] THREE.WebGLProgram: Shader Error 0 - VALIDATE_STATUS false ... ERROR: 0:83: 'null' : undeclared identifier
(the generated line follows, ending ... * null ) + 1.0 ) ) );)

Myth vs fact

Myth

TSL is just a nicer way to write GLSL.

Fact

It is a graph builder. The same program becomes WGSL or GLSL, and three.js can also change the graph for you, for example to add fog, tone mapping or shadows.

Myth

I can use normal JavaScript maths on nodes.

Fact

Only the node functions and methods. JavaScript operators and Math.* calls do not build nodes.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Because the output depends on the backend, test a TSL effect on both: forceWebGL: true on a machine with WebGPU is the quickest way. A difference points at a driver, an unsupported node, or float precision.

  • ▸

    In the generated shader the colour literals are already linear. If you read colours off the printed shader and compare them with your hex values, the numbers will not match: that is colour management, not a bug.

  • ▸

    getShaderAsync() is a debug tool, not for production: it compiles the object first to get the text. Use it while developing, and log the result only when something looks wrong.

Remember this

  1. 1

    The problem this solves: GLSL (WebGL) and WGSL (WebGPU) are two different languages. Writing every effect twice, and keeping both in step, is a recipe for bugs. TSL gives one source that works on both backends, and it is plain JavaScript, so you get functions, imports and editor help for free.

  2. 2

    The pieces. Everything comes from three/tsl. Values: float(1), vec3(1, 0.4, 0.2), color("#ff6b2c"), uniform(3). Inputs the renderer fills in: uv(), time (seconds), positionLocal, normalView. Maths: sin, mix, step, fract, smoothstep, dot, normalize. Operators are methods: a.add(b), a.mul(b), a.sub(b), a.pow(2). Swizzles work as in GLSL: p.xxy, v.yxz.

  3. 3

    Fn makes your own function. const pulse = Fn(([speed, depth]) => sin(time.mul(speed)).mul(depth).add(1)) is a reusable piece of shader. Its arguments come as an array, and calling pulse(speed, 0.3) returns a node. Inside Fn, .toVar() makes a variable you can change with .assign() and .addAssign(), and If(...) is a branch that runs on the GPU.

  4. 4

    Two JavaScript habits that bite. A JavaScript operator on nodes does not add them: a + b gives the text [object Object][object Object]. And a JavaScript if (node) is always true, because the node is an object: use If() or select(). The third example shows both. Math.sin(node) is wrong too: it returns NaN, and then .mul fails (see the break-it below).

  5. 5

    Colours are converted. color("#2a9df4") is an sRGB hex. The shader works in linear light, so three.js converts it: the generated shader contains 0.0231, 0.3372, 0.9047, not the 42, 157, 244 of the hex. If you pass numbers to vec3, they are used as they are, in linear.

  6. 6

    Documentation is thin, so read the source. The TSL guide is short and changes between releases. The truth is in node_modules/three/src/nodes/ and the list of everything exported is node_modules/three/src/Three.TSL.js. Every export has a doc comment with its parameters. And renderer.debug.getShaderAsync() shows the shader text TSL became, which is how you debug a graph.

Explain it without notes

01

Why can one TSL effect run on both WebGPU and WebGL 2?

02

A teammate writes material.colorNode = a + b with two nodes. What do they get and how do they fix it?

Practice

01

Write a Fn called stripes that takes a count and returns 0 or 1 across the uv.x, and use it for a plane's colorNode.

02

Print the vertex shader of a mesh with a positionNode and find the line that sets positionLocal.

Done when you can

  • Write a colour effect in TSL with uv(), mix and step

  • Make a reusable Fn and drive it with a uniform

  • Spot the JavaScript habits that do not build nodes

  • Read the generated WGSL or GLSL with getShaderAsync

  • Find a TSL function's definition in node_modules/three/src/nodes/