WebGPU has been "almost ready" for years. In 2026 it's actually ready — Chrome, Edge, Firefox 130+, and Safari 18.2 all ship stable support, including on mobile. Three.js made the switch painless starting with r171: you change one import line and the renderer swaps out. But when you're porting a game — with custom shaders, thousands of instanced objects, and a particle system that was already fighting the garbage collector — the one-liner is just the first step. This post is a practical walkthrough of the migration, the gotchas I hit, and the performance wins that actually showed up in the profiler.
The one-line swap (and what it gives you for free)
The official migration path is embarrassingly simple. You swap your import from the default Three.js entry point to the WebGPU bundle:
// Before — WebGL
import * as THREE from 'three';
const renderer = new THREE.WebGLRenderer({ antialias: true });
// After — WebGPU
import * as THREE from 'three/webgpu';
const renderer = new THREE.WebGPURenderer({ antialias: true });
await renderer.init(); // ← this is new and mandatory
That last line matters. WebGPURenderer initialization is asynchronous
— it has to request a GPU adapter and device before it can do anything. If you call
renderer.render() before init() resolves, you get a silent black canvas.
In a game with an requestAnimationFrame loop that fires immediately, this means
restructuring your boot sequence so the first frame waits for the renderer. I wrapped
mine in an async init() function that calls await renderer.init()
before entering the loop — took about two minutes to wire up.
Everything built into Three.js — MeshStandardMaterial,
MeshPhysicalMaterial, lights, shadows, post-processing via the new
PostProcessing class — works out of the box. If your game only uses stock
materials and the built-in fog/shadow pipeline, you may genuinely be done after the import
swap and the async init. For a simple demo scene I tested, it rendered identically frame-one
with zero other changes.
The three/webgpu entry point also includes a silent WebGL 2 fallback. If the
browser doesn't support WebGPU (rare now, but possible on older devices), it drops to WebGL 2
automatically. You don't have to write any detection logic yourself.
Shader migration: GLSL → TSL
The one-liner doesn't help if you have custom shaders. ShaderMaterial and
RawShaderMaterial are GLSL-only and are ignored by the WebGPU backend — they
still work if the renderer falls back to WebGL, but on WebGPU they just won't render.
Three.js's answer is TSL — Three Shader Language. It's a JavaScript-level API for writing shaders as node graphs that Three.js compiles to either GLSL or WGSL depending on the active backend. Write once, run on both. The syntax feels like writing math in JavaScript rather than writing GLSL strings:
// GLSL (old)
uniform float uTime;
varying vec2 vUv;
void main() {
float wave = sin(vUv.x * 10.0 + uTime) * 0.1;
gl_Position = projectionMatrix * modelViewMatrix
* vec4(position + normal * wave, 1.0);
}
// TSL (new)
import { uniform, sin, uv, positionLocal,
normalLocal, mul, add } from 'three/tsl';
const uTime = uniform(0.0);
const wave = sin(add(mul(uv().x, 10.0), uTime)).mul(0.1);
material.positionNode = add(positionLocal, mul(normalLocal, wave));
It looks more verbose than GLSL, but you get two things for free: the shader runs on both WebGL and WebGPU, and you can compose nodes programmatically. Need ten variants of the same wave shader with different frequencies? That's a loop in JavaScript generating node trees, not ten hand-maintained GLSL files.
The migration cost depends entirely on how many custom shaders you have. A game with one or two effects takes an afternoon. A game with a full custom pipeline — deferred rendering, screen-space reflections, custom tone mapping — is a bigger project. In my case I had three custom shaders (a terrain displacement, a muzzle-flash glow, and a scope overlay) and converting them to TSL took roughly a day.
Compute shaders: the actual game-changer
The performance win from swapping the renderer alone was modest in my tests — maybe 5–10% fewer milliseconds per frame on draw-call-heavy scenes, which is consistent with what Babylon.js and others have reported. Nice, but not transformative.
The transformative part is compute shaders. WebGL doesn't have them. WebGPU does,
and Three.js exposes them through TSL. The concept: instead of running a vertex-and-fragment
pipeline that draws pixels, a compute shader runs arbitrary parallel work on the GPU and
writes results into buffers. Physics, particle updates, spatial hashing, pathfinding — anything
that's embarrassingly parallel and was previously burning CPU cycles in your
requestAnimationFrame callback.
The pattern in Three.js looks like this:
import { instancedArray, wgslFn, compute } from 'three/tsl';
// Create a GPU-side buffer for 10,000 particle positions
const positions = instancedArray(10000, 'vec3');
// Define the compute kernel in WGSL
const updateKernel = wgslFn(`
fn main(@builtin(global_invocation_id) id: vec3u) {
let i = id.x;
var pos = positions[i];
pos.y -= 0.02; // gravity
if (pos.y < 0.0) { pos.y = 5.0; } // respawn
positions[i] = pos;
}
`);
// Dispatch 10,000 threads — runs every frame on the GPU
const computeNode = updateKernel({ positions }).compute(10000);
// In your render loop:
renderer.compute(computeNode);
renderer.render(scene, camera);
I moved a muzzle-flash particle system — 5,000 particles that were being updated in a
for loop on the CPU every frame — to a compute shader. The CPU time for that
system dropped from about 2.8 ms per frame to effectively zero (the GPU runs it
alongside the render pass). On a mid-range laptop that was the difference between
a solid 60 fps and occasional drops to 45.
Instanced meshes get a compute-powered upgrade
In WebGL, instanced meshes are already the standard way to draw many identical objects
(bullets, foliage, debris). You set up an InstancedMesh with a count and
update each instance's transform matrix from JavaScript every frame. It works, but the
CPU update loop scales linearly — 10,000 instances means 10,000 matrix writes on the main
thread.
With WebGPU, you can write instance positions directly from a compute shader. The
instancedArray function in TSL creates a GPU buffer, the compute kernel
updates it in parallel, and the instanced draw reads from the same buffer. The CPU
never touches the per-instance data at all. For a debris field with 10k fragments,
this cut the per-frame update from ~4 ms on the CPU to a sub-millisecond GPU dispatch.
Post-processing: the new API
If you were using EffectComposer from three/examples/jsm/postprocessing,
you'll need to switch to the new PostProcessing class that ships with
the WebGPU bundle. The old EffectComposer was built on WebGL framebuffers and render targets;
the new one uses TSL pass nodes:
import { PostProcessing } from 'three/webgpu';
import { pass, bloom } from 'three/tsl';
const postProcessing = new PostProcessing(renderer);
const scenePass = pass(scene, camera);
const bloomPass = bloom(scenePass, {
threshold: 0.85,
intensity: 1.2,
radius: 0.4,
});
postProcessing.outputNode = bloomPass;
// In render loop: postProcessing.render() instead of renderer.render()
Most common effects — bloom, SSAO, tone mapping, FXAA — have TSL equivalents. If you had a custom post-processing pass, it needs to be rewritten as a TSL node, which is the same amount of work as the shader migration above.
Browser support: where it actually stands
As of mid-2026, WebGPU is shipped and stable in Chrome (desktop + Android), Edge, Safari 18.2
(macOS + iOS), and Firefox 130+. The "is it ready?" question from 2024 is settled — the
answer is yes for any project that can also tolerate a WebGL 2 fallback on the long tail
of older devices. The three/webgpu import handles the fallback automatically,
so you don't have to branch your code.
The one caveat: compute shaders require WebGPU. If a user's browser falls back to WebGL 2,
compute-dependent features (GPU particles, GPU physics) won't run. You need either a CPU
fallback path or to accept that those features are WebGPU-only. For a game, my approach was
to keep a simple CPU particle path as a fallback and only enable the compute version when
navigator.gpu is available.
Is it worth porting now?
If your game uses only stock materials and the built-in pipeline — yes, unconditionally. The port is a five-minute import swap that gives you lower draw-call overhead, automatic WebGL fallback, and future access to compute shaders when you want them.
If you have a lot of custom GLSL — the calculus is different. TSL migration is real work, and GLSL shaders still function fine on WebGL 2, which runs everywhere. The reason to port is if you need compute shaders (for GPU particles, physics, or spatial queries that are currently bottlenecking the CPU) or if you want a single shader codebase that targets both backends going forward.
For me, the biggest practical win wasn't the renderer swap itself — it was compute shaders for particle systems and instanced-mesh updates. That moved real milliseconds off the CPU and made 60 fps feel effortless on hardware that was previously borderline. If your game is CPU-bound on per-object updates, that's the feature worth porting for.