Skip to content

WebGPU and TSL

A Three.js guide for coding agents. Also covers WebGPURenderer, webgpu renderer setup, three webgpu, TSL, three shading language, node material, and 26 more.

Show all 32 aliases

WebGPURenderer, webgpu renderer setup, three webgpu, TSL, three shading language, node material, MeshStandardNodeMaterial, colorNode, positionNode, Fn shader function, uniform node, tsl uniform, varying node, uv node, positionLocal, positionWorld, normalLocal, time node, timer node, glsl to tsl, convert shader to tsl, compute shader, GPUComputationRenderer replacement, instancedArray, storage buffer, instanceIndex, renderer.compute, shadermaterial migration, onBeforeCompile replacement, wgslFn, webgl2 fallback, forceWebGL

Core Philosophy

  • WebGPURenderer is the modern renderer and the official direction of three.js. It targets WebGPU (WGSL) and transparently falls back to WebGL 2 (GLSL) when WebGPU is unavailable, so one codebase covers both
  • TSL (Three.js Shading Language) is a node-based shader system written in plain JavaScript. You compose nodes instead of writing shader strings, and three.js compiles the graph to WGSL or GLSL depending on the active backend
  • The old pattern of ShaderMaterial with GLSL strings, and onBeforeCompile string surgery, does not carry over. TSL replaces both, and the replacement is more composable: you extend built-in lighting instead of reimplementing it
  • Write shaders once in TSL and never think about which backend runs them. Hand-written GLSL locks you out of the WebGPU backend; hand-written WGSL locks you out of the fallback

Setup

  • Import the renderer from three/webgpu and TSL functions from three/tsl. The three/webgpu build re-exports the whole core, so it replaces the plain three import in a WebGPU project, do not ship both builds
import * as THREE from "three/webgpu";
import { uv, time, vec3, Fn, uniform } from "three/tsl";

const renderer = new THREE.WebGPURenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

await renderer.init();
renderer.setAnimationLoop(() => renderer.render(scene, camera));
  • Initialization is async because requesting a GPU adapter and device is async. Either await renderer.init() once before the first frame, or skip it and use setAnimationLoop, which handles the pending init internally. Calling plain render() before init completes is the classic first-frame error
  • Backend selection is automatic: WebGPU if the browser supports it, WebGL 2 otherwise. Force the fallback for testing with new THREE.WebGPURenderer({ forceWebGL: true })
  • To branch your own UI on support, use the capability helper
import WebGPU from "three/addons/capabilities/WebGPU.js";

if (WebGPU.isAvailable() === false) {
  document.body.appendChild(WebGPU.getErrorMessage());
}
  • In an import map, point three, three/webgpu, and three/tsl at three.webgpu.js / three.tsl.js so every module resolves to the same build

Differences from WebGLRenderer

  • Async lifecycle: init(), renderAsync(), computeAsync() return promises. The sync render() still works inside setAnimationLoop once the renderer is initialized
  • Materials: node materials only. ShaderMaterial and RawShaderMaterial are WebGL-only and will not render; onBeforeCompile string injection has no equivalent because there are no GLSL strings to patch
  • Built-in classic materials (MeshStandardMaterial etc.) work unchanged: under WebGPURenderer they are backed by their node equivalents, so existing scenes render without edits. Only custom shader code needs porting
  • Compute: renderer.compute() runs GPU compute passes, which WebGLRenderer never had. On the WebGL 2 fallback, compute is emulated with limits (see caveats)
  • Post-processing uses the node-based THREE.PostProcessing / pass() pipeline from three/tsl, not EffectComposer
  • Color management, tone mapping, shadow maps, and the scene graph API are the same. For most non-shader code the renderer is a drop-in swap

Node materials

  • Every built-in material has a node variant: MeshBasicNodeMaterial, MeshStandardNodeMaterial, MeshPhysicalNodeMaterial, MeshPhongNodeMaterial, PointsNodeMaterial, LineBasicNodeMaterial, SpriteNodeMaterial, and a bare NodeMaterial when you want full control
  • A node material is the normal material plus override slots. Every scalar or texture property has a *Node twin that accepts a TSL node graph and takes precedence over the plain property
const material = new THREE.MeshStandardNodeMaterial();
material.colorNode = texture(map).mul(vec3(1, 0.8, 0.8));
material.roughnessNode = uv().y;              // gradient roughness
material.positionNode = positionLocal.add(offset); // vertex displacement
  • The key slots: colorNode (base color), opacityNode, normalNode, emissiveNode, positionNode (vertex position, local space), alphaTestNode, depthNode. Standard adds metalnessNode and roughnessNode; Physical adds clearcoatNode, sheenNode, transmissionNode, iridescenceNode and friends; Sprite adds rotationNode and scaleNode
  • Escape hatches: fragmentNode replaces the entire fragment stage (no lighting applied), vertexNode the entire vertex stage. Prefer the granular slots, they keep lights, shadows, fog, and tone mapping for free, which is the whole advantage over ShaderMaterial
  • Slots compose: setting colorNode on a MeshStandardNodeMaterial feeds your value into full PBR lighting. That one line replaces what used to require forking hundreds of lines of lighting GLSL

TSL basics

  • Everything is a node. Constants: float(1), vec2(0, 1), vec3(...), vec4(...), color(0xff0000), int(), uint(), bool(), plus matrix types
  • Math is method chaining, since JavaScript has no operator overloading
const wave = positionLocal.y.mul(4).add(time).sin().mul(0.5).add(0.5);
  • Operators: .add() .sub() .mul() .div() .mod(), comparisons .equal() .lessThan() .greaterThan() ..., logic .and() .or() .not(), assignment .assign() .addAssign() .mulAssign() ...
  • Math functions mirror GLSL and are imported from three/tsl: sin, cos, pow, sqrt, abs, floor, fract, clamp, mix, step, smoothstep, length, distance, dot, cross, normalize, reflect, min, max, plus extras like saturate, oneMinus, remap. Constants PI, EPSILON
  • Swizzling works as properties: myVec.xyz, myVec.zyx, myVec.rgb, uv().yx
  • Variables: .toVar() creates a mutable shader variable you can .assign() to later. Without it, reusing a node just reuses the expression
const col = vec3(0).toVar();
col.assign(vec3(1, 0, 0));

Fn: shader functions

  • Fn wraps a JavaScript arrow function into a reusable shader function. Arguments arrive as an array or a destructurable object
const oscSine = Fn(([t = time]) => {
  return t.mul(Math.PI * 2).sin().mul(0.5).add(0.5);
});

material.colorNode = vec3(oscSine(), 0, 0);
  • The body runs once in JavaScript to build the node graph, then compiles to a real WGSL/GLSL function. Plain JS if/for inside a Fn runs at build time (static branching); use If/Loop for runtime branching

Uniforms

  • uniform(value) creates a CPU-updatable input. Mutate .value from JavaScript each frame, no needsUpdate required
const progress = uniform(0);
material.opacityNode = progress;

// in the loop
progress.value = Math.sin(t) * 0.5 + 0.5;
  • Self-updating uniforms: uniform(0).onFrameUpdate(({ object }) => object.position.y) and the onRenderUpdate / onObjectUpdate variants pull values automatically
  • uniformArray([...], 'color') for arrays of uniforms

Attributes and varyings

  • attribute('name', 'vec3') reads a custom BufferAttribute from the geometry. Built-ins have dedicated nodes: uv(index), vertexColor(), instanceIndex, vertexIndex
  • Varyings are mostly implicit: use a vertex-stage node (like positionLocal) inside a fragment slot and TSL inserts the varying for you. Force vertex-stage computation with vertexStage(node) when an expensive expression should run per vertex, or declare one explicitly with varying(node)

Built-in nodes you will actually use

  • Position: positionGeometry (raw attribute), positionLocal (after skinning/morph, the one to displace), positionWorld, positionView, positionViewDirection
  • Normals: normalGeometry, normalLocal, normalView, normalWorld
  • UV and screen: uv(), screenUV, screenSize
  • Camera and model: cameraPosition, cameraViewMatrix, cameraProjectionMatrix, modelWorldMatrix, modelViewMatrix
  • Time: time (seconds, running), deltaTime (seconds since last frame). Older tutorials use timerLocal() / timerGlobal(); those are the legacy names for the same idea, prefer time
  • Utility: hash(seed) for cheap randomness, range(min, max) for per-instance random attributes, oscSine(time) and friends for oscillators, rotateUV, mix, remap

Control flow

import { If, Loop, select, Discard } from "three/tsl";

If(dist.lessThan(0.5), () => {
  col.assign(vec3(1, 0, 0));
}).Else(() => {
  col.assign(vec3(0));
});

Loop(10, ({ i }) => {
  acc.addAssign(sample(i));
});

const clamped = select(x.greaterThan(1), 1.0, x); // ternary
Discard(alpha.lessThan(0.01));                     // discard fragment

Custom shading: TSL vs GLSL

A dissolve-style effect that would previously need onBeforeCompile:

import { Fn, uniform, uv, vec3, smoothstep, Discard } from "three/tsl";
import { mx_noise_float } from "three/tsl"; // built-in noise

const progress = uniform(0);
const material = new THREE.MeshStandardNodeMaterial({ color: 0x334455 });

const dissolve = Fn(() => {
  const n = mx_noise_float(positionLocal.mul(4));
  Discard(n.lessThan(progress));
  const edge = smoothstep(progress, progress.add(0.1), n).oneMinus();
  return vec3(2, 0.6, 0.1).mul(edge); // emissive glow at the edge
});

material.emissiveNode = dissolve();
  • No shader chunks, no #include ordering, no string replacement, and PBR lighting plus shadows still apply
  • Noise comes built in via the MaterialX nodes: mx_noise_float, mx_noise_vec3, mx_fractal_noise_float, mx_worley_noise_float. Stop pasting Perlin GLSL into projects

Converting GLSL patterns to TSL

GLSLTSL
position (attribute)positionGeometry
transformedpositionLocal
vWorldPositionpositionWorld
vNormal (view space)normalView
vUvuv()
vColorvertexColor()
modelMatrixmodelWorldMatrix
viewMatrixcameraViewMatrix
projectionMatrixcameraProjectionMatrix
cameraPositioncameraPosition
uniform float uTimeconst uTime = uniform(0)
varying vec2 vFooimplicit, or varying(node)
texture2D(map, vUv)texture(map, uv())
gl_FragColor = ...material.fragmentNode = ...
diffuseColor.rgb = ...material.colorNode = ...
a * b + ca.mul(b).add(c)
mix(a, b, t)mix(a, b, t) or a.mix(b, t)
if / forIf(...) / Loop(...)
discardDiscard(condition)
  • Mechanical porting recipe: uniforms become uniform() objects, the fragment body becomes a Fn, operators become method chains, varyings usually disappear, and the final assignment targets a material slot instead of gl_FragColor

Compute shaders (GPGPU)

  • WebGPURenderer runs real compute passes, replacing the render-to-texture GPUComputationRenderer hack. State lives in storage buffers, not ping-pong textures
import { Fn, instancedArray, instanceIndex, deltaTime, hash } from "three/tsl";

const count = 100_000;
const positions = instancedArray(count, "vec3");
const velocities = instancedArray(count, "vec3");

const init = Fn(() => {
  const pos = positions.element(instanceIndex);
  pos.assign(vec3(hash(instanceIndex), 0, hash(instanceIndex.add(1))));
})().compute(count);

const update = Fn(() => {
  const pos = positions.element(instanceIndex);
  const vel = velocities.element(instanceIndex);
  vel.y.subAssign(deltaTime.mul(9.8));
  pos.addAssign(vel.mul(deltaTime));
})().compute(count);

await renderer.computeAsync(init); // once

renderer.setAnimationLoop(() => {
  renderer.compute(update);        // every frame
  renderer.render(scene, camera);
});
  • Render the same buffers directly, no texture readback: on a SpriteNodeMaterial or PointsNodeMaterial, set material.positionNode = positions.element(instanceIndex) (or positions.toAttribute()) and draw an instanced mesh with count instances
  • instancedArray(countOrTypedArray, type) allocates a storage buffer indexed per instance; attributeArray is the per-vertex twin. Both accept a count or an existing typed array
  • instanceIndex is the compute thread id. Advanced: workgroupId, localId, atomics (atomicAdd etc.), and workgroupBarrier() are available for WGSL-grade algorithms
  • One-off passes (like init) can run with computeAsync and awaited; per-frame passes just call compute in the loop

Interop caveats

  • Browser support: WebGPU ships in Chromium browsers, and reached Firefox and Safari in their 2025 releases. Coverage is broad on current versions but not universal on older ones, which is exactly why the WebGL 2 fallback matters. Verify against caniuse for your real support floor
  • The fallback is automatic but not free: TSL graphs compile to GLSL fine, but compute shaders on the WebGL 2 backend are emulated via transform feedback with real limitations (no storage textures, no atomics, restricted buffer access patterns). If compute is core to the experience, treat WebGPU as required and gate with WebGPU.isAvailable()
  • Raw shader escape hatches exist: wgslFn embeds a WGSL function, glslFn a GLSL one, usable inside a node graph. They pin you to one backend, so reserve them for ported one-offs
  • ShaderMaterial, onBeforeCompile, and EffectComposer remain WebGL-only. Libraries built on them (older postprocessing packages, drei shader helpers) need TSL-era equivalents
  • Do not mix builds: importing three and three/webgpu as separate modules in one app duplicates the library and breaks instanceof checks. Map both specifiers to the WebGPU build

Migrating existing ShaderMaterial code

  1. Swap the renderer to WebGPURenderer first. Built-in materials keep working; only custom shaders break, so you can migrate incrementally
  2. For each ShaderMaterial, decide what it really did. Most "custom shaders" are a lit material plus a twist, so pick the matching node material and override only the relevant slot instead of porting the whole fragment shader
  3. Port uniforms to uniform() objects and keep updating .value; the update code in your loop usually survives unchanged
  4. Port the GLSL body with the table above: chained methods, If/Loop, built-in position/normal/uv nodes instead of varyings
  5. Vertex displacement moves to positionNode (displace positionLocal); full-screen or unlit fragment work moves to fragmentNode on a NodeMaterial
  6. Replace GPUComputationRenderer sims with instancedArray plus renderer.compute, and EffectComposer stacks with THREE.PostProcessing and the pass() / bloom() node effects
  7. Verify on both backends: run once normally and once with forceWebGL: true, since the GLSL path occasionally surfaces graph mistakes the WGSL path tolerates, and vice versa

Common mistakes

  • Calling renderer.render() before await renderer.init() (or outside setAnimationLoop), so the first frames throw or draw nothing
  • Importing node functions from three instead of three/tsl, or mixing the three and three/webgpu builds in one bundle
  • Using JavaScript if or for inside a Fn and expecting runtime branching; those execute once at graph build time. Runtime control flow is If, Switch, Loop, select
  • Writing a + b on nodes, which concatenates or NaNs instead of adding. Every operation is a method: a.add(b)
  • Assigning to a node that was never made a variable. .assign() requires a .toVar() target
  • Reaching for fragmentNode when a slot override (colorNode, emissiveNode) would keep lighting, shadows, and fog intact
  • Recreating uniforms every frame instead of mutating .value on one uniform() instance
  • Porting GLSL noise functions by hand when mx_noise_float and friends ship with TSL
  • Shipping a compute-dependent experience without gating on WebGPU.isAvailable(), then hitting the emulated compute limits of the WebGL 2 fallback
  • Keeping EffectComposer or onBeforeCompile code paths alive after the renderer swap and wondering why they silently do nothing

Use this guidance in your coding agent

Install the Better Design MCP once. Your agent then loads this page with one call.

get-three-js-guide({ topic: "webgpu-and-tsl" })
claude mcp add --scope user better-design --transport http https://better-design.com/api/mcp --header "Authorization: Bearer <YOUR_API_KEY>"
Browse related design systems