TSL: The Three.js Shading Language

TSL — the Three.js Shading Language — lets you write shaders in JavaScript and have Three.js compile them to WGSL for the WebGPU backend or GLSL for the WebGL one. One source, both APIs, chosen at runtime by whatever the visitor's browser can actually do.

That sounds like a convenience. It is closer to a structural fix. The single hardest problem in shipping WebGPU commercially is that a meaningful share of your audience does not have it, and maintaining two hand-written shader codebases to cover that is not realistic for most teams. TSL removes the choice: write the shading once, let the renderer decide the backend.

The examples below were checked against three@0.181.1, the version this site runs.

Why TSL exists

Three problems converged, and TSL answers all three.

1. Two shader languages. WebGL speaks GLSL, WebGPU speaks WGSL, and they are not close enough for a search-and-replace. Supporting both by hand means every custom material written and debugged twice, forever.

2. Patching built-in shaders was always miserable. If you wanted Three.js's standard lighting and one custom effect, the established technique was onBeforeCompile: string-replacing fragments of Three.js's own shader source. It worked. It also broke on Three.js upgrades, could not be composed with anything else, and required knowing the internals of a shader you did not write. Every non-trivial Three.js project I have inherited had at least one of these, and nobody remembered how it worked.

3. Composition was impossible. GLSL has no good way to say "this material is the standard one, but with the vertex positions displaced by noise and the emissive driven by a fresnel". You either reimplemented the whole material or patched strings.

TSL replaces all of it with a graph. You build node objects in JavaScript and assign them to specific slots on a material — colorNode, positionNode, emissiveNode, roughnessNode. Three.js keeps its lighting and shadow handling and compiles your graph into the right place. Nodes are values, so they compose, and they are ordinary JavaScript, so you can put them in a function, a module, or an array.

The node model

The one idea to internalise: in TSL you are not writing code that runs, you are building an expression that gets compiled.

uv().x.mul(2.0) does not multiply anything when that line executes. It constructs a node representing "the x component of the UV, multiplied by two". Three.js walks that graph later and emits WGSL or GLSL from it.

Everything else follows:

  • Operators are methods. JavaScript cannot overload *, so it is .mul(), .add(), .sub(), .div(). Chains read left to right, which is arguably clearer than nested GLSL: a.mul(b).add(c) versus a * b + c.
  • Swizzling works as properties. .x, .y, .xy, .rgb all behave as you would expect from GLSL.
  • Types are constructors. float(), vec2(), vec3(), vec4() — and plain JavaScript numbers are usually converted for you.
  • Because it is JavaScript, the language is yours. You can build node graphs in a loop, keep them in an array, return them from a function, or import them from a module. That is the part GLSL never had.
import { uv, vec3, mix, sin, time } from "three/tsl"

// A horizontal gradient that animates
const t = sin(time).mul(0.5).add(0.5)          // 0..1 oscillation
const gradient = mix(vec3(0.1, 0.2, 0.4), vec3(0.9, 0.4, 0.2), uv().x)
const colorNode = gradient.mul(t)

Note time — TSL supplies built-in nodes for things you would otherwise wire up by hand: time, uv(), positionLocal, positionWorld, normalLocal, normalWorld, cameraPosition, screenUV, instanceIndex. No uniform plumbing, no per-frame update code.

A complete first material

Here is the whole loop — renderer, material, node graph — in the form the WebGPU backend needs:

import * as THREE from "three"
import { WebGPURenderer, MeshStandardNodeMaterial } from "three/webgpu"
import { uv, vec3, mix, smoothstep, mx_fractal_noise_float, time, positionLocal } from "three/tsl"

const renderer = new WebGPURenderer({ antialias: true })
await renderer.init()                    // required: adapter setup is async

const material = new MeshStandardNodeMaterial()

// Fractal noise over the surface, drifting with time
const noise = mx_fractal_noise_float(
  positionLocal.mul(2.0).add(vec3(0.0, 0.0, time.mul(0.1))),
  4,      // octaves
  2.0,    // lacunarity
  0.5,    // diminish
  1.0     // amplitude
)

// Band it into two colours with a soft edge
const banded = smoothstep(0.35, 0.45, noise)
material.colorNode = mix(vec3(0.05, 0.08, 0.12), vec3(0.35, 0.55, 0.75), banded)

// Keep the standard material's own lighting; just add a glow at the bands
material.emissiveNode = vec3(0.2, 0.4, 0.9).mul(banded).mul(0.4)

const mesh = new THREE.Mesh(new THREE.SphereGeometry(1, 128, 128), material)

Two things worth noticing.

The lighting still works. This is a MeshStandardNodeMaterial, so shadows, environment maps and PBR response are intact. Only the base colour and emissive were replaced. With ShaderMaterial you would have had to reimplement all of it.

mx_fractal_noise_float came for free. TSL ships the MaterialX noise functions, so the noise everyone hand-rolls badly is a built-in. Writing that in GLSL is fifty lines you copied from somewhere and do not fully trust.

The hero on this site's hire page is a close cousin of this — a WebGPU renderer with a TSL fractal-noise shader and a pointer-driven ripple.

Uniforms, attributes and control flow

Uniforms are values you change from JavaScript. uniform() returns a node with a mutable .value:

import { uniform, vec3 } from "three/tsl"

const uIntensity = uniform(1.0)
const uTint = uniform(new THREE.Color(0x4f7fa8))

material.colorNode = uTint.mul(uIntensity)

// later, per frame or on an event — no material recompile
uIntensity.value = 2.5

Attributes read per-vertex geometry data, including custom ones you added to the BufferGeometry:

import { attribute } from "three/tsl"
const aRandom = attribute("aRandom")   // matches geometry.setAttribute("aRandom", ...)

Functions are Fn(), and they are the composition unit — write once, use across materials:

import { Fn, float, dot, normalize, cameraPosition, positionWorld, normalWorld, pow, oneMinus, clamp } from "three/tsl"

const fresnel = Fn(([power = float(3.0)]) => {
  const viewDir = normalize(cameraPosition.sub(positionWorld))
  const facing = clamp(dot(viewDir, normalize(normalWorld)), 0.0, 1.0)
  return pow(oneMinus(facing), power)
})

material.emissiveNode = vec3(0.3, 0.6, 1.0).mul(fresnel(float(4.0)))

Control flow exists, capitalised to distinguish it from the JavaScript keywords: If(), Loop(), plus Break(), Continue() and Discard(). Use them the way you would in a shader — sparingly, and preferring mix/step arithmetic when the condition varies per-pixel, for the reason branching costs on a GPU.

Because Fn returns an ordinary JavaScript value, a shader library becomes a module of exported functions. That is a genuinely better authoring story than GLSL's #include preprocessor conventions, and it is the part of TSL I would not want to give up.

Compute shaders in TSL

TSL also writes compute shaders, which is where the WebGPU-only capability becomes reachable without learning WGSL.

The pieces are storage() to wrap a buffer attribute, instanceIndex for the invocation's index, and .compute(count) to build a dispatchable node:

import { Fn, storage, instanceIndex, float } from "three/tsl"

const count = 100000
const positions = new THREE.StorageInstancedBufferAttribute(count, 3)
const positionStorage = storage(positions, "vec3", count)

const updateParticles = Fn(() => {
  const pos = positionStorage.element(instanceIndex)
  pos.y.addAssign(float(0.01))          // write back into the buffer
})().compute(count)

renderer.computeAsync(updateParticles)  // once per frame

The state lives in a GPU buffer and never travels to JavaScript. In WebGL the same effect meant encoding positions into floating-point textures and ping-ponging framebuffers — workable, and genuinely unpleasant.

Be clear-eyed about one thing: this part is WebGPU-only. A compute pass has no GLSL equivalent to compile down to, so unlike the material work above it does not fall back. If your visual depends on compute, you need a different, simpler behaviour for visitors on the WebGL path — designed deliberately, not discovered in production.

Porting existing GLSL

Most of the translation is mechanical:

GLSL TSL
a * b + c a.mul(b).add(c)
vUv uv()
uniform float uTime; const uTime = uniform(0) — or just time
mix(a, b, t) mix(a, b, t)
smoothstep(e0, e1, x) smoothstep(e0, e1, x)
1.0 - x oneMinus(x)
gl_FragColor = ... material.colorNode = ...
gl_Position displacement material.positionNode = ...
if (c) {} else {} If(c, () => {}).Else(() => {})

The GLSL to TSL converter on this site does the mechanical pass for you. Expect to review the result rather than paste it — the arithmetic converts cleanly, but structural decisions do not: what was one monolithic ShaderMaterial usually wants to become a couple of node assignments on a MeshStandardNodeMaterial, keeping the built-in lighting you were previously reimplementing.

Two things that catch people during a port:

  • onBeforeCompile has no equivalent. It is not supported alongside node materials; it is replaced by them. Patching strings into a node-compiled shader is not a thing, and that is the point.
  • Precision and defaults differ between the backends. A shader that looks right compiled to WGSL is usually right in GLSL too, but "usually" is not "always" — check both, especially anything with high-frequency noise or large coordinate values.

When not to use TSL

It is not the right answer everywhere, and I would rather say so than sell it.

You are learning shaders for the first time. Learn the concepts in GLSL. The available teaching material — Shadertoy, The Book of Shaders, a decade of tutorials — is vastly deeper, and the ideas transfer completely. Come to TSL once "what does this code do per pixel" is instinctive.

You are not using Three.js. TSL is a Three.js abstraction. In a different engine, or raw WebGPU, it is not available to you.

You need exact control over the emitted shader. TSL generates the code. For most work that is fine and often better. If you are hand-optimising instruction counts on a constrained target, the generated output is one step removed from you.

Your project is WebGL-only and always will be. Then the cross-compilation, which is TSL's main argument, buys you nothing — though the composition and the built-in noise functions are still real benefits.

You need a large body of existing GLSL to keep working unchanged. Porting is real work. The converter helps; it does not eliminate it.

For new commercial work in Three.js that has to serve everyone, though, I now default to TSL. The fallback story alone justifies it.

F.A.Q

Frequently asked questions

What does TSL stand for?

Three.js Shading Language. It is not a separate language with its own compiler — it is a JavaScript API for building shader node graphs, which Three.js then compiles to WGSL for the WebGPU backend or GLSL for the WebGL one.

Does TSL work with WebGL, or is it WebGPU only?

Both, and that is the main reason to use it. The same node graph compiles to GLSL on the WebGL backend and WGSL on the WebGPU one. The exception is compute shaders, which are WebGPU-only because WebGL has no equivalent to compile down to — so anything depending on compute needs a separate, simpler path for WebGL visitors.

Is TSL slower than hand-written GLSL?

Not meaningfully in practice. It compiles to shader code ahead of the render, so there is no per-frame interpretation cost, and the generated output is generally comparable to what you would write by hand. If you are counting instructions on a very constrained target you will want to inspect the output, but for typical web work the difference is not what limits you.

Can I still use onBeforeCompile with node materials?

No. onBeforeCompile patches strings into the legacy shader pipeline and has no equivalent in the node system — the node system replaces it. That is a genuine migration cost for projects with existing string patches, and also the point: assigning a node to colorNode is composable and survives Three.js upgrades in a way string replacement never did.

How do I convert my existing GLSL shaders to TSL?

Start with the GLSL to TSL converter on this site for the mechanical pass — operators to method chains, uniforms to uniform nodes. Then review structurally: a monolithic ShaderMaterial usually wants to become a few node assignments on a MeshStandardNodeMaterial, which lets you delete the lighting code you were previously reimplementing by hand.

Why is it .mul() instead of *?

JavaScript does not support operator overloading, so a node object cannot define what * means. Chained methods are the workaround. Chains read left to right, which some people find clearer than nested GLSL expressions; either way it is the syntactic price of describing shaders in JavaScript rather than a design preference.

Do I need to await renderer.init() with TSL?

You need it with WebGPURenderer, regardless of TSL — requesting a GPU adapter and device is asynchronous, so rendering before init resolves will fail. Use renderAsync() rather than render() as well. On the WebGL renderer neither applies.

Is TSL stable enough for production?

It is what the WebGPU side of Three.js is built on, so in that sense it is the supported path rather than an experiment. It does move faster than the older WebGL APIs: pin your Three.js version, read the release notes when upgrading, and expect occasional renames. I use it on client work and treat the version pin as part of the deal.

Try it

Tools and courses on this site

Bring a shader you already have

The quickest way to understand the node model is to watch a GLSL shader you wrote turn into it. The converter runs in the browser and costs nothing.

Open the GLSL to TSL converter

Keep reading

Would rather have this built than build it? Interactive 3D Website Development and 3D Data Visualization are the service pages for it, or see everything I do.