Core Philosophy
WebGPURendereris 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
ShaderMaterialwith GLSL strings, andonBeforeCompilestring 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/webgpuand TSL functions fromthree/tsl. Thethree/webgpubuild re-exports the whole core, so it replaces the plainthreeimport 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 usesetAnimationLoop, which handles the pending init internally. Calling plainrender()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, andthree/tslatthree.webgpu.js/three.tsl.jsso every module resolves to the same build
Differences from WebGLRenderer
- Async lifecycle:
init(),renderAsync(),computeAsync()return promises. The syncrender()still works insidesetAnimationLooponce the renderer is initialized - Materials: node materials only.
ShaderMaterialandRawShaderMaterialare WebGL-only and will not render;onBeforeCompilestring injection has no equivalent because there are no GLSL strings to patch - Built-in classic materials (
MeshStandardMaterialetc.) work unchanged: underWebGPURendererthey 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, whichWebGLRenderernever had. On the WebGL 2 fallback, compute is emulated with limits (see caveats) - Post-processing uses the node-based
THREE.PostProcessing/pass()pipeline fromthree/tsl, notEffectComposer - 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 bareNodeMaterialwhen you want full control - A node material is the normal material plus override slots. Every scalar or texture property has a
*Nodetwin 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 addsmetalnessNodeandroughnessNode; Physical addsclearcoatNode,sheenNode,transmissionNode,iridescenceNodeand friends; Sprite addsrotationNodeandscaleNode - Escape hatches:
fragmentNodereplaces the entire fragment stage (no lighting applied),vertexNodethe entire vertex stage. Prefer the granular slots, they keep lights, shadows, fog, and tone mapping for free, which is the whole advantage overShaderMaterial - Slots compose: setting
colorNodeon aMeshStandardNodeMaterialfeeds 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 likesaturate,oneMinus,remap. ConstantsPI,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
Fnwraps 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/forinside aFnruns at build time (static branching); useIf/Loopfor runtime branching
Uniforms
uniform(value)creates a CPU-updatable input. Mutate.valuefrom JavaScript each frame, noneedsUpdaterequired
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 theonRenderUpdate/onObjectUpdatevariants pull values automatically uniformArray([...], 'color')for arrays of uniforms
Attributes and varyings
attribute('name', 'vec3')reads a customBufferAttributefrom 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 withvertexStage(node)when an expensive expression should run per vertex, or declare one explicitly withvarying(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 usetimerLocal()/timerGlobal(); those are the legacy names for the same idea, prefertime - 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
#includeordering, 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
| GLSL | TSL |
|---|---|
position (attribute) | positionGeometry |
transformed | positionLocal |
vWorldPosition | positionWorld |
vNormal (view space) | normalView |
vUv | uv() |
vColor | vertexColor() |
modelMatrix | modelWorldMatrix |
viewMatrix | cameraViewMatrix |
projectionMatrix | cameraProjectionMatrix |
cameraPosition | cameraPosition |
uniform float uTime | const uTime = uniform(0) |
varying vec2 vFoo | implicit, or varying(node) |
texture2D(map, vUv) | texture(map, uv()) |
gl_FragColor = ... | material.fragmentNode = ... |
diffuseColor.rgb = ... | material.colorNode = ... |
a * b + c | a.mul(b).add(c) |
mix(a, b, t) | mix(a, b, t) or a.mix(b, t) |
if / for | If(...) / Loop(...) |
discard | Discard(condition) |
- Mechanical porting recipe: uniforms become
uniform()objects, the fragment body becomes aFn, operators become method chains, varyings usually disappear, and the final assignment targets a material slot instead ofgl_FragColor
Compute shaders (GPGPU)
WebGPURendererruns real compute passes, replacing the render-to-textureGPUComputationRendererhack. 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
SpriteNodeMaterialorPointsNodeMaterial, setmaterial.positionNode = positions.element(instanceIndex)(orpositions.toAttribute()) and draw an instanced mesh withcountinstances instancedArray(countOrTypedArray, type)allocates a storage buffer indexed per instance;attributeArrayis the per-vertex twin. Both accept a count or an existing typed arrayinstanceIndexis the compute thread id. Advanced:workgroupId,localId, atomics (atomicAddetc.), andworkgroupBarrier()are available for WGSL-grade algorithms- One-off passes (like
init) can run withcomputeAsyncand awaited; per-frame passes just callcomputein 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:
wgslFnembeds a WGSL function,glslFna GLSL one, usable inside a node graph. They pin you to one backend, so reserve them for ported one-offs ShaderMaterial,onBeforeCompile, andEffectComposerremain WebGL-only. Libraries built on them (older postprocessing packages, drei shader helpers) need TSL-era equivalents- Do not mix builds: importing
threeandthree/webgpuas separate modules in one app duplicates the library and breaksinstanceofchecks. Map both specifiers to the WebGPU build
Migrating existing ShaderMaterial code
- Swap the renderer to
WebGPURendererfirst. Built-in materials keep working; only custom shaders break, so you can migrate incrementally - 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 - Port uniforms to
uniform()objects and keep updating.value; the update code in your loop usually survives unchanged - Port the GLSL body with the table above: chained methods,
If/Loop, built-in position/normal/uv nodes instead of varyings - Vertex displacement moves to
positionNode(displacepositionLocal); full-screen or unlit fragment work moves tofragmentNodeon aNodeMaterial - Replace
GPUComputationRenderersims withinstancedArrayplusrenderer.compute, andEffectComposerstacks withTHREE.PostProcessingand thepass()/bloom()node effects - 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()beforeawait renderer.init()(or outsidesetAnimationLoop), so the first frames throw or draw nothing - Importing node functions from
threeinstead ofthree/tsl, or mixing thethreeandthree/webgpubuilds in one bundle - Using JavaScript
iforforinside aFnand expecting runtime branching; those execute once at graph build time. Runtime control flow isIf,Switch,Loop,select - Writing
a + bon 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
fragmentNodewhen a slot override (colorNode,emissiveNode) would keep lighting, shadows, and fog intact - Recreating uniforms every frame instead of mutating
.valueon oneuniform()instance - Porting GLSL noise functions by hand when
mx_noise_floatand 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
EffectComposeroronBeforeCompilecode paths alive after the renderer swap and wondering why they silently do nothing