Three.js From Zero · Article s4-10
S4-10 TSL Procedural Materials
TSL Procedural Materials — write shaders in TypeScript
TSL = Three.js Shading Language. Write shaders as JS function graphs, compile to WGSL for WebGPU and GLSL for WebGL. Hot-reload friendly, composable, node-based under the hood. S4 finale.
1. Why TSL?
The traditional shader workflow:
- Write GLSL in a string (or a
.glslfile). - Set uniforms by name.
- Debug via trial-and-error in browser console.
- Port to WGSL by hand when you want WebGPU.
TSL does all this from JavaScript:
import { uv, vec3, mix, sin, time } from 'three/tsl';
import { MeshBasicNodeMaterial } from 'three/webgpu';
const mat = new MeshBasicNodeMaterial();
mat.colorNode = mix(
vec3(1, 0.2, 0.4),
vec3(0.2, 0.4, 1),
sin(uv().x * 10 + time)
);
No string. No GLSL. Runs on both WebGL and WebGPU from one source. That's TSL's pitch.
2. The node graph — it's all nodes
Every TSL value is a node. uv() returns a node. sin(x) returns a node. When you assign material.colorNode = ..., you're handing Three.js a graph to compile.
// Not JS addition. Node addition.
const wave = sin(time.mul(2)).mul(0.5).add(0.5);
// Now 'wave' is a node that evaluates sin(time*2)*0.5+0.5 in the shader.
const color = mix(vec3(0, 0, 0), vec3(1, 1, 1), wave);
material.colorNode = color;
You're building a small DAG that gets compiled to GPU code. Same graph works on WebGL (GLSL) and WebGPU (WGSL).
3. Procedural noise in TSL
No textures, no assets. Generate everything from math:
import { positionLocal, sin, cos, vec3, float } from 'three/tsl';
// Simple procedural stripes
const stripes = sin(positionLocal.y.mul(20)).step(float(0));
material.colorNode = vec3(stripes);
Value noise, Perlin, Simplex, FBM — all buildable from primitives. TSL ships mx_noise_float, mx_fractal_noise_float, etc., from MaterialX standard library.
4. Live demo — four procedural materials in TSL
All shaders generated by JS node graphs. No GLSL strings. Switch between them, tweak uniforms live.
5. Compute shaders in TSL (the WebGPU payoff)
The real superpower is compute. Previously: write a ShaderMaterial, set up a FBO, render a fullscreen quad to it — that's your "compute." Awkward.
TSL compute:
import { storage, instanceIndex, vec3, Fn } from 'three/tsl';
// GPU buffer with 1M positions
const positions = storage(buffer, 'vec3', 1_000_000);
// Update kernel — runs once per index in parallel
const updateParticles = Fn(() => {
const pos = positions.element(instanceIndex);
pos.y = pos.y.add(0.01);
});
// Dispatch on every frame
renderer.compute(updateParticles.compute(1_000_000));
That's GPGPU (S2-06) with a first-class API. A million particles updated in a few lines.
6. Hot reload
Because the graph is JavaScript, you can rebuild it and swap material.colorNode without touching shader strings or recompiling. Vite HMR → dev loop feels like tweaking CSS.
7. Composability
The killer feature. You write a function that returns a node:
function checker(uv, scale) {
const cell = uv.mul(scale).floor().xy;
return cell.x.add(cell.y).modInt(2);
}
// Use it anywhere
material.colorNode = checker(uv(), 10);
displacementNode = checker(uv(), 8).mul(0.3);
That's impossible in GLSL text. TSL makes shaders modular.
8. What about legacy ShaderMaterial?
Still works. Not going away. But:
- Only runs on WebGL. WebGPU uses node materials.
- No hot reload of logic — only uniforms.
- Can't be composed from JS functions as cleanly.
For greenfield projects targeting 2025+, use TSL. For maintaining legacy demos, keep ShaderMaterial.
9. A tour of useful TSL primitives
| Primitive | What it does |
|---|---|
uv() | Per-fragment UV coordinate as a node |
positionLocal | Position in object space |
positionWorld | Position in world space |
normalWorld | Normal in world space |
time | Seconds since start |
mx_noise_float(p) | Perlin noise |
mx_fractal_noise_float(p, oct) | FBM |
mx_worley_noise_float(p) | Voronoi cells |
texture(t, uv) | Sample a texture |
Fn(() => ...) | Define a reusable function |
If(...)/Loop(...) | Control flow |
storage(buf, type, n) | GPU buffer for compute |
10. Migration path
You have a ShaderMaterial-based demo. Want to move to TSL. Steps:
- Identify uniform inputs: light direction, time, colors.
- Identify varyings: UV, normal, world position — all available as TSL nodes.
- Rewrite GLSL expression as TSL node graph.
- Swap
ShaderMaterialforMeshBasicNodeMaterial/MeshStandardNodeMaterial. - Assign to
colorNode,emissiveNode, etc.
11. Season 4 recap
We started with PBR math from scratch. Climbed through IBL, shadows, SSR, volumetric fog, SSS, hair, ocean, stylization, and landed on TSL. Every demo is a working single-file HTML.
Season 5 next: Advanced rendering topics — GI, path tracing, denoising, Nanite-style culling, raytracing in WebGL, deferred shading. We're going deeper.
12. Takeaways
- TSL = JS-native shader authoring. Node graph under the hood.
- Targets both WebGL and WebGPU from one source.
- Hot reloadable, composable, modular.
- Compute shaders become first-class (GPGPU without boilerplate).
- Use TSL for greenfield. ShaderMaterial still works for legacy.