WebGPU for browser games in 2026: compute shader particles and a WebGL fallback
WebGPU now runs in Chrome, Edge, Firefox and Safari, with gaps on Linux and Android. Particles with a compute shader, and a clean fallback to WebGL 2.
- WebGPU is on by default in Chrome/Edge (Windows, macOS, ChromeOS, Android 12+), Firefox 141 on Windows, Firefox on Apple Silicon Macs, and Safari 26 on every Apple platform
- Remaining gaps as of October 2026: Firefox on Linux, Android and Intel Macs; Chrome on some Linux GPUs; Windows ARM64
- A compute shader updates hundreds of thousands of particles on the GPU without the data round-tripping through JavaScript each frame
- Check both navigator.gpu and the adapter you get back; if either is missing, fall back to WebGL 2
WebGPU is the web’s new graphics and compute API. The thing WebGL never had, and games benefit from directly, is compute shaders: general-purpose GPU work such as particles, simple physics or culling, without faking it through a fragment shader. In late 2025 web.dev announced support in all major browsers. This article covers what “supported” really means, writes particles with a compute shader, and sets up a fallback to WebGL 2.
Facts checked against the web.dev post (25 Nov 2025), the gpuweb Implementation Status wiki (last edited 2 Oct 2026), MDN and the W3C WebGPU specification, as of October 2026. Code follows the patterns in MDN and the spec.
What “every browser” means
| Browser | On by default | Not yet / behind a flag |
|---|---|---|
| Chrome / Edge | Windows, macOS, ChromeOS since 113; Android 12+ (ARM/Qualcomm/Intel) since 121; Linux on Intel Gen12+ since 144 and NVIDIA on Wayland since 147 | other Linux setups, Windows ARM64 |
| Firefox | Windows since 141; Apple Silicon Macs: macOS 26 since 145, all macOS versions since 147 | Intel Macs and Linux (Nightly only), Android (flag) |
| Safari | macOS, iOS, iPadOS, visionOS 26 | — |
Read as a game developer: most desktop and iPhone players get WebGPU, but Firefox players on Linux, Android and Intel Macs, plus Android older than 12, do not. A public game still needs a fallback.
“Supported” is not “every machine”.
navigator.gpucan exist whilerequestAdapter()still returnsnullbecause the GPU or driver is blocklisted.
Should you start now?
Decide by what your game does, not by how new the API is.
| Situation | Advice |
|---|---|
| Ordinary 2D or 3D game on an engine | turn on the engine’s WebGPU backend if it has one and let it fall back; no need to touch the API directly |
| Many particles or a large simulation | worth writing your own compute shader, with a smaller CPU path |
| Core players on Linux or older Android | build on WebGL 2 and treat WebGPU as an extra |
| Internal tools or demos where you control the browser | WebGPU only is fine |
WebGPU’s shading language is WGSL, not GLSL. Moving from WebGL means rewriting shaders or using an engine that translates them, and that is usually the biggest part of a migration, more than the JavaScript API.
Check readiness at both levels
// returns { device } if WebGPU works, null if we should fall back to WebGL 2
async function getGPU() {
if (!("gpu" in navigator)) return null; // no API, or not a secure context
const adapter = await navigator.gpu.requestAdapter().catch(() => null);
if (!adapter) return null; // API present, GPU/driver unusable
const device = await adapter.requestDevice();
device.lost.then((info) => console.warn("GPU device lost:", info.message));
return { device };
}
device.lost matters for games. A long-backgrounded tab, a driver reset or a new monitor can lose the device, and you have to recreate it and re-upload your data.
Particles with a compute shader
The idea: keep every particle in a storage buffer on the GPU, run a compute shader each frame to update position and velocity, then draw from the same buffer. The data never comes back to JavaScript.
const WGSL = /* wgsl */ `
struct Particle { pos: vec2f, vel: vec2f };
@group(0) @binding(0) var<storage, read_write> ps: array<Particle>;
@group(0) @binding(1) var<uniform> dt: f32;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3u) {
let i = id.x;
if (i >= arrayLength(&ps)) { return; } // guard the spare threads in the last workgroup
var p = ps[i];
p.vel.y -= 9.8 * dt;
p.pos += p.vel * dt;
if (p.pos.y < -1.0) { p.pos.y = -1.0; p.vel.y = -p.vel.y * 0.5; } // hit the floor, bounce
ps[i] = p;
}`;
function createParticles(device, N) {
const init = new Float32Array(N * 4); // pos.x, pos.y, vel.x, vel.y
for (let i = 0; i < N; i++) {
init[i * 4 + 2] = Math.random() * 2 - 1;
init[i * 4 + 3] = Math.random() * 4;
}
const particles = device.createBuffer({
size: init.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(particles, 0, init);
const params = device.createBuffer({ size: 16, usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST });
const pipeline = device.createComputePipeline({
layout: "auto",
compute: { module: device.createShaderModule({ code: WGSL }), entryPoint: "main" },
});
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: particles } },
{ binding: 1, resource: { buffer: params } },
],
});
return function step(dt) {
device.queue.writeBuffer(params, 0, new Float32Array([dt, 0, 0, 0]));
const enc = device.createCommandEncoder();
const pass = enc.beginComputePass();
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(Math.ceil(N / 64));
pass.end();
device.queue.submit([enc.finish()]);
return particles; // hand to the render pass as a vertex buffer
};
}
The render pass that draws from the same buffer (an instanced draw reading pos as an attribute) is left out to keep this short. Feed dt from a fixed timestep game loop so particles fall at the same speed on every display.
Worked example: what 100,000 particles cost
Particle holds two vec2f, 8 bytes each = 16 bytes per particle.
- Memory: 100,000 × 16 B = 1,600,000 B ≈ 1.6 MB
- Workgroups: 100,000 ÷ 64 = 1,562.5 → round up to 1,563
- Threads actually launched: 1,563 × 64 = 100,032 → 32 spare, which is why the
if (i >= arrayLength(&ps))guard exists
Ceilings from the spec’s default limits (real devices may allow more; request higher limits in requestDevice):
maxComputeWorkgroupsPerDimension= 65,535 → a one-dimensional dispatch covers 65,535 × 64 = 4,194,240 particles; beyond that use the y dimension or several dispatchesmaxStorageBufferBindingSize= 128 MiB = 134,217,728 B → ÷ 16 = 8,388,608 particles per bindingmaxComputeInvocationsPerWorkgroup= 256 →@workgroup_size(64)is well within budget
Compare the old WebGL way: update on the CPU and upload 1.6 MB to the GPU every frame. At 60 fps that is 1.6 × 60 = 96 MB/s through bufferSubData, on top of a 100,000-iteration JavaScript loop per frame.
Falling back to WebGL 2
Two approaches work in practice.
1. Use an engine that falls back for you. three.js’s WebGPURenderer tries WebGPU first and otherwise uses a WebGL 2 backend; set forceWebGL: true to test that path on a machine that has WebGPU. web.dev also lists Babylon.js, PlayCanvas and Unity among those supporting WebGPU.
2. Write both paths yourself. Keep the particle maths identical and run it in different places. Without WebGPU, run it on the CPU with fewer particles:
const gpu = await getGPU();
const N = gpu ? 100_000 : 10_000; // ten times fewer on the CPU path
function stepCPU(buf, dt) { // same formula as the WGSL, line for line
for (let i = 0; i < buf.length; i += 4) {
buf[i + 3] -= 9.8 * dt;
buf[i] += buf[i + 2] * dt;
buf[i + 1] += buf[i + 3] * dt;
if (buf[i + 1] < -1) { buf[i + 1] = -1; buf[i + 3] = -buf[i + 3] * 0.5; }
}
}
Cutting particle counts on the fallback path is normal. Players get a playable game with a little less sparkle, which beats a black screen.
Common mistakes
- Checking only
navigator.gpuand not the adapter. Machines with a blocklisted GPU fail atrequestDevice - Testing over http on a LAN IP. The API disappears outside a secure context; use localhost or HTTPS
- No guard for spare threads, so the compute shader writes past the end of the array
- Reading results back to JavaScript every frame.
mapAsyncwaits on the GPU and throws away the benefit of compute; keep the buffer on the GPU - Not handling
device.lost. The tab comes back and the game is frozen; recreate the device and buffers - Testing only Chrome on your own machine. Open Firefox on Linux or use
forceWebGLto exercise the fallback before every release
Sources
FAQ
Can I use WebGPU over plain http?
No. The whole API is available only in a secure context, meaning HTTPS, or localhost while developing. If navigator.gpu is undefined on a test server, check this first.
I use three.js. Do I need to write a fallback?
No. three.js's WebGPURenderer tries WebGPU first and falls back to a WebGL 2 backend if the browser lacks it, and forceWebGL lets you test the WebGL path on purpose.
Is WebGPU always faster than WebGL?
No. A simple scene where the GPU is not the bottleneck may barely change. The clear wins are compute shaders and lower CPU overhead per draw. Measure with your own game before concluding.
Commands and settings were tested on sample systems. Try them on a test machine before production.