Three.js From Zero · Article s4-10

TSL Procedural Materials — write shaders in TypeScript

TSL Procedural Materials — write shaders in TypeScript is Article s4-10 of Three.js From Zero, a MasterAllArts free interactive lesson for artists learning creative 3D on the web.

Season 4 · Article 10 · Finale

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.