Three.js From Zero · Article s4-10

S4-10 TSL Procedural Materials

Season 4 · Article 10 · Finale

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.

🏁 Season 4 finale — Advanced Rendering concludes

1. Why TSL?

The traditional shader workflow:

  • Write GLSL in a string (or a .glsl file).
  • 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.

TSL is stable in recent Three.js. It's the direction the library is going. Learning it now is future-proofing.

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

PrimitiveWhat it does
uv()Per-fragment UV coordinate as a node
positionLocalPosition in object space
positionWorldPosition in world space
normalWorldNormal in world space
timeSeconds 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:

  1. Identify uniform inputs: light direction, time, colors.
  2. Identify varyings: UV, normal, world position — all available as TSL nodes.
  3. Rewrite GLSL expression as TSL node graph.
  4. Swap ShaderMaterial for MeshBasicNodeMaterial / MeshStandardNodeMaterial.
  5. 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.