Last episode I ended on a little cliffhanger and then just... walked off. We spent the whole thing learning to build tools other people can pick up and use, and right at the very end I mentioned, almost in passing, that while we were busy making our work solid and shareable there's a whole new engine arriving in the browser that's about to change what the word fast even means for graphics. Allez, that's today. This is the one I've been genuinly excited to get to, because WebGPU is the biggest shift in browser graphics since, well, since the browser could do graphics at all :-).
I want to be honest up front about the feeling of this episode, because it's a bit different from most. This is not a "here's a neat trick" one. It's more like the day someone hands you the keys to a car you've only ever pushed. So I'm going to go slower than usual, show you the shape of the whole thing, and not pretend it's simpler than it is. WebGPU asks a little more of you at the start than the friendly Canvas API did back in episode 2. But what it gives back is the actual modern graphics card, spoken to properly, and that is a lot.
Let me tell you where this comes from, because it makes everything else click. Way back in episode 46 we did something that felt like magic at the time: we pushed a particle system onto the GPU so we could animate way more points than the CPU could ever handle. And it worked, but it worked through a kind of clever lie. WebGL was built for drawing, so to make it compute things we had to smuggle our data in as if it were colours in a texture, do the maths in a fragment shader, and read the pixels back out as numbers. It was brilliant and it was a hack, and every creative coder who pushed WebGL hard eventually hit the same wall: the tool was fighting us.
And in the performance episode (that was episode 132) we spent a lot of energy just managing that friction - batching draw calls, avoiding the dreaded read-back that stalls everything, tip-toeing around a design from 2011. The whole time, the graphics card sitting in your machine could do so much more. It has thousands of little cores that all crunch numbers at the same time, and WebGL only ever let us at them sideways, through the drawing door.
WebGPU opens the other door. It's a modern API, built this decade, that talks to the GPU the way native engines like Vulkan and Metal do. It has a proper front door for drawing and a proper front door for pure computing - no more disguising your particles as pixels. That second door is called a compute shader, and it is the whole reason this episode exists. Makes sense so far?
Right, let's actually touch it. Every WebGPU program starts the same way, and it's a tiny ritual worth memorising because you'll type it a hundred times. You ask the browser for an adapter (that's a handle to a physical GPU), then ask the adapter for a device (your actual connection you send work to). Both are async, because the browser might have to wake the hardware up.
// the WebGPU handshake. every program starts with these three lines.
async function initWebGPU(canvas) {
const adapter = await navigator.gpu.requestAdapter(); // pick a physical GPU
const device = await adapter.requestDevice(); // open a connection to it
const context = canvas.getContext("webgpu"); // the canvas we'll draw into
return { device, context };
}
But here's the thing I want you to do from day one, and it's a habit straight out of the accessibility and tools episodes: check that it's even there first. WebGPU is new, and a reader on an older browser or a locked-down machine simply won't have it. A tool that explodes with a cryptic error the moment it loads is a tool nobody keeps. So we ask politely and we have a plan B.
// never assume WebGPU exists. check, and fall back gracefully.
async function getDevice() {
if (!navigator.gpu) {
console.warn("No WebGPU here - falling back to canvas.");
return null; // your caller drops back to episode 2's 2D canvas
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
console.warn("A browser with WebGPU, but no usable GPU adapter.");
return null;
}
return adapter.requestDevice();
}
That if (!navigator.gpu) line is your manners again - the same lesson as last episode, just pointed at a shiny new toy. Build the fancy path and the humble one, and nobody gets pushed out of the room.
Once you've got a device, you have to tell the canvas how to present what the GPU draws. This is one-time setup: you pick the pixel format the screen prefers, and hand the device over.
// configure the canvas context once, before any drawing.
function configureCanvas(device, context) {
const format = navigator.gpu.getPreferredCanvasFormat(); // usually "bgra8unorm"
context.configure({
device,
format, // the pipeline must match this exact format later
alphaMode: "premultiplied", // lets the canvas blend nicely over the page
});
return format; // hold onto this - the render pipeline needs it
}
Keep that format value around. WebGPU is strict in a way WebGL never was: the format your canvas expects and the format your drawing pipeline produces have to match exactly, or it refuses to run. That strictness feels annoying for about a day and then you realise it's why WebGPU errors are so much clearer - it checks everything up front instead of failing silently at 3am.
Now, back in episodes 21 and 32 we wrote our first shaders in GLSL. WebGPU brings its own shading language, called WGSL (people say "wig-sil"), and I promise it's less scary than a new language sounds. If you can read the GLSL we already wrote, you can read this. It's a bit more explicit about types, that's the main thing - vec2f instead of vec2, that sort of honesty.
Here's the tiniest possible pair of shaders: a vertex shader that decides where points go, and a fragment shader that decides what colour each pixel is. Same two jobs as always, new spelling.
// a minimal WGSL shader pair - vertex places points, fragment colours pixels.
@vertex
fn vs(@location(0) pos: vec2f) -> @builtin(position) vec4f {
return vec4f(pos, 0.0, 1.0); // pass the 2D point through as a clip-space position
}
@fragment
fn fs() -> @location(0) vec4f {
return vec4f(1.0, 0.35, 0.2, 1.0); // a warm orange, same for every pixel
}
Those @ bits are attributes - little labels telling the GPU what each thing is for. @location(0) means "this comes from slot 0 of the data I hand you", @builtin(position) means "this output is the on-screen position". It reads like paperwork at first, but that paperwork is exactly what lets WebGPU catch your mistakes before it runs a single frame. You hand this whole string to the device as a shader module:
// compile a WGSL string into a shader module the GPU understands.
const module = device.createShaderModule({ code: wgslSource });
Every graphics API has a rite of passage: draw one triangle. It's the "hello world" of the GPU, and honestly the first time it appears on screen it feels great every single time, even now. To get there we need a pipeline - a full description of how to turn data into pixels - and some vertex data in a GPU buffer.
First the data. A triangle is three points, and in WebGPU we hand the GPU a plain Float32Array and copy it into a buffer that lives in GPU memory.
// three 2D points, uploaded into a GPU buffer the shader can read.
const verts = new Float32Array([
0.0, 0.5, // top
-0.5, -0.5, // bottom-left
0.5, -0.5, // bottom-right
]);
const vertexBuffer = device.createBuffer({
size: verts.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST, // it's vertices, and we'll write to it
});
device.queue.writeBuffer(vertexBuffer, 0, verts); // copy our array up to the GPU
Then the pipeline, which ties the shaders, the data layout, and the output format together into one object. This is the verbose part - I won't pretend otherwise - but read it slowly and every line is just answering an honest question: which shaders? what shape is my data? what am I drawing into?
// the render pipeline: shaders + data layout + output format, described up front.
const pipeline = device.createRenderPipeline({
layout: "auto",
vertex: {
module,
entryPoint: "vs",
buffers: [{
arrayStride: 8, // 2 floats * 4 bytes each = 8 bytes per point
attributes: [{ shaderLocation: 0, offset: 0, format: "float32x2" }],
}],
},
fragment: {
module,
entryPoint: "fs",
targets: [{ format }], // MUST match the canvas format from earlier
},
primitive: { topology: "triangle-list" }, // every 3 points is one triangle
});
And finally we actually draw, once per frame. WebGPU makes you record your commands into an encoder and then submit them all at once - which sounds like extra steps but is exactly what makes it fast, because the whole batch of work crosses over to the GPU in one go instead of dribbling across.
// record a frame's worth of commands, then submit the whole batch at once.
function drawTriangle(device, context, pipeline, vertexBuffer) {
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(), // draw into the visible canvas
clearValue: { r: 0.06, g: 0.06, b: 0.08, a: 1 }, // a soft near-black ground
loadOp: "clear", // wipe to clearValue first
storeOp: "store", // then keep what we drew
}],
});
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(3); // three vertices -> one triangle
pass.end();
device.queue.submit([encoder.finish()]); // hand the recorded batch to the GPU
}
That's a triangle. It is genuinly more code than the two lines of Canvas API that drew our first shape back in episode 2, and I'd be lying if I said otherwise. But look at what each block is: setup you do once (device, pipeline) and a tiny loop you do every frame (encode, draw, submit). The verbosity is nearly all one-time. Once the scaffold is standing, adding to it is quick.
Okay. The triangle was the polite introduction. This is why we came. Remember the hack from episode 46, where we disguised particle positions as pixel colours just to do maths on the GPU? Forget all of it. WebGPU lets us run a shader whose only job is computing numbers, with no drawing involved at all, reading and writing plain arrays in GPU memory. This is the thing WebGL never let us do honestly.
Here's a compute shader that nudges a big array of particle positions - and I want you to notice how it barely looks like graphics code anymore. It looks like a normal loop body, except the loop is gone. The GPU runs this function once per particle, all at the same time, in paralel.
// a COMPUTE shader: pure number-crunching, no drawing. one invocation per particle.
@group(0) @binding(0) var<storage, read_write> positions: array<vec2f>;
@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(&positions)) { return; } // guard: don't run past the array
var p = positions[i];
p.x = p.x + 0.1 * dt; // drift every particle to the right
if (p.x > 1.0) { p.x = p.x - 2.0; } // wrap around when it leaves the screen
positions[i] = p;
}
The mind-bending bit is @workgroup_size(64) and that global_invocation_id. You don't write a for loop. Instead you tell the GPU "run this little function for every particle, in batches of 64", and it fans the work out across all its cores at once. If you have 100,000 particles, the GPU doesn't do them one after another - it does great swathes of them simultaneously. That's the power we were reaching for through a keyhole in episode 46, now just handed to us through the front door.
Wiring it up mirrors the render side. You make a compute pipeline, and you make a bind group - which is just a labelled bundle saying "here's the data this shader named at @binding(0) and @binding(1)".
// a compute pipeline, plus the bind group that feeds it its data.
const computePipeline = device.createComputePipeline({
layout: "auto",
compute: { module: computeModule, entryPoint: "main" },
});
const bindGroup = device.createBindGroup({
layout: computePipeline.getBindGroupLayout(0),
entries: [
{ binding: 0, resource: { buffer: particleBuffer } }, // the positions array
{ binding: 1, resource: { buffer: dtBuffer } }, // the timestep uniform
],
});
And then, each frame, you dispatch the work. This one line is where a hundred thousand particles get updated - not in a JavaScript loop that would choke your tab, but in one instruction to the GPU.
// dispatch the compute work: update ALL particles in one GPU instruction.
function stepParticles(device, computePipeline, bindGroup, count) {
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(computePipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(Math.ceil(count / 64)); // count/64 groups of 64 = every particle
pass.end();
device.queue.submit([encoder.finish()]);
}
Divide your particle count by the workgroup size, dispatch that many groups, done. A hundred thousand points moved in a fraction of a millisecond, on hardware that was sitting mostly idle while WebGL made us jump through hoops.
Here's the part that made me sit back in my chair the first time. In the old world, the particle positions lived in JavaScript, the GPU drew them, and if we wanted the simulation on the GPU too we had that painful read-back stall we fought in episode 132 - copying data down from the GPU and back up every frame, which quietly murders performance. WebGPU makes that whole problem vanish, because the compute shader and the render shader can read the exact same buffer. The data never has to come home.
// the whole loop: compute step and draw step, sharing ONE buffer. no read-back, ever.
function frame() {
const encoder = device.createCommandEncoder();
// 1. COMPUTE: move every particle on the GPU
const cpass = encoder.beginComputePass();
cpass.setPipeline(computePipeline);
cpass.setBindGroup(0, computeBindGroup);
cpass.dispatchWorkgroups(Math.ceil(count / 64));
cpass.end();
// 2. RENDER: draw the SAME buffer the compute pass just wrote
const rpass = encoder.beginRenderPass({ colorAttachments: [/* ...canvas view... */] });
rpass.setPipeline(renderPipeline);
rpass.setVertexBuffer(0, particleBuffer); // same particleBuffer - it never left the GPU
rpass.draw(count);
rpass.end();
device.queue.submit([encoder.finish()]); // both passes, one submission
requestAnimationFrame(frame);
}
Read that structure a couple of times, because it's the shape of nearly every serious WebGPU sketch you'll ever write: compute pass, then render pass, sharing a buffer, submitted together. The positions are calculated and drawn without ever making the round trip back to JavaScript. That single idea - keep the data on the GPU - is the whole performance story of this new era, and it's why sketches that stuttered at ten thousand particles now shrug at a million.
Let me pull us up out of the code, because it's easy to drown in the API and miss the point. What does having real GPU compute in a browser tab actually change for us as artists? A few things I'm genuinely giddy about. Flocking and boids simulations (remember episode 50?) that ran a few hundred agents on the CPU can now run hundreds of thousands, so you get true murmuration, not a token gesture. Reaction-diffusion and cellular automata, which are just "do a bit of maths in every cell every frame", are a perfect fit for compute shaders - the whole grid updates in one dispatch. Physics with real particle counts. Fluids. Slime-mould agents by the million. The stuff that used to be a research demo becomes something you can put on a web page and hand to a friend.
And it's not only about more. It's about a cleaner way to think. When your simulation and your drawing share one buffer and one language, the seam between "computing the art" and "showing the art" starts to disappear, and that's a lovely place to make things from. You stop budgeting every particle and start asking what you actually want to see.
I'll leave you with the honest caveat too, because I'd want one. WebGPU is verbose, it's stricter than anything we've used, and it's still young enough that you'll meet the occasional rough edge. For a quick doodle, the humble Canvas API from episode 2 is still the right tool, and it always will be - I reach for it constantly. WebGPU is for when you've hit the wall, when the CPU is melting and WebGL is fighting you. Knowing which tool a piece needs is its own skill, and picking the sledgehammer for a thumbtack helps nobody. Use the big engine when the work asks for the big engine.
Step back and feel the shift. For a hundred and forty episodes we've been getting steadily closer to the metal - from p5.js, down to raw Canvas, into GLSL shaders, and now to a proper modern conversation with the graphics card itself. This is about as deep as the well goes for browser graphics, and you're standing at the bottom of it looking up, which is a genuinely good place to be. You don't need to master WebGPU this week. You need to have touched it once - run the triangle, watched a compute shader move a buffer - so that the next time your art hits a wall, you know a door exists and roughly where the handle is.
And that matters right now especially, because we've spent this whole recent stretch building the habits of serious work - version control, tests, accessibility, documentation, tools for others, and now the fastest engine in the browser. That's not a random pile. It's a toolkit. So the natural next move isn't another technique at all. It's to take everything we've gathered and build one real thing with it, start to finish, the way you'd build an actual project rather than a lesson - a proper tool with its own life. Hold that thought, because that's exactly where we go from here. For now, one small assignment: open the WebGPU samples in your browser (search "webgpu samples"), find the compute-particles one, and just read it with today's episode next to you. You'll recognise every piece - adapter, device, pipeline, compute pass, shared buffer. The moment that wall of unfamiliar code turns into "oh, I know what this bit does", you've arrived. That recognition is the whole goal of today :-).
navigator.gpu exists first and fall back gracefully, same manners as alwaysvec2f, not vec2), and those @location/@builtin labels are the paperwork that lets WebGPU catch mistakes before it runs@workgroup_size(64) plus a dispatch runs your function once per item, all in parallel across thousands of cores. A hundred thousand particles moved in one instructionSo that's the new engine, out of the box and idling in the driveway. You don't have to drive it far today - just sit in it, turn the key, watch the triangle and then the particles appear, and let it sink in that the whole graphics card is finally yours to talk to properly. Everything we've built lately was quietly preparing for a moment like this, where the tool stops being the limit and your idea does. Go run the samples, read them with this episode open, and let that little "oh, I recognise this" feeling wash over you. Merci for reading, and I'll see you next time when we finally put all of it together into something real :-).
Sallukes! Thanks for reading.
X