Compute shaders: the GPU beyond graphics
A compute shader is a general parallel program the GPU runs with no triangles and no pixels involved at all, reading and writing arbitrary storage buffers through the same bind groups and the same record-then-submit machinery the rest of this module built, which is why WebGPU's real reason to exist is compute rather than faster rendering.
Compute shaders: the GPU beyond graphics
Every lesson in this module so far has been in service of drawing triangles. This one isn't. The flagship lesson named "the GPU could only draw" as WebGL's biggest limitation in the long run, bigger than any performance detail — because a modern GPU is not a triangle-drawing peripheral, it's a massively parallel general-purpose processor that happens to be very good at graphics. A compute shader is how WebGPU lets you use that processor directly: a program with no vertices, no rasterization, no fragments, no framebuffer, that reads and writes arbitrary GPU memory in parallel. This lesson is the capstone of the module, and it turns out to need almost nothing new — just one more WGSL entry-point type, one more kind of buffer, and the exact encoder-and-queue machinery from the last lesson pointed at a different kind of pass.
A third shader stage: @compute
Lesson 5 covered @vertex and @fragment entry points. WGSL has a third:
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3u) {
// this function's body runs once per invocation
}@compute marks this as a compute entry point rather than part of the render pipeline. @workgroup_size(64) is not a hint — it's a fixed part of the shader's compiled definition, declaring that every workgroup of this shader consists of 64 invocations running together. Unlike the vertex and fragment stages, a compute shader isn't handed vertices or fragments by fixed-function hardware upstream of it. It has no upstream stage at all. Its only inputs are whatever buffers you bind to it and built-in values like global_invocation_id that tell each invocation which "slot" of the overall job it's responsible for.
The execution model: dispatch a grid, get a grid of invocations
You don't call a compute shader once per item the way a CPU loop would. You tell the GPU how many workgroups to run, and the hardware runs every invocation in every one of them, in parallel:
pass.dispatchWorkgroups(nx, ny, nz);If workgroup_size is 64 and you dispatch nx workgroups, the GPU runs 64 * nx invocations total, each one an independent run of the same shader body. This is the same idea the GPU pipeline lesson made about vertex and fragment shaders — every invocation is independent and identical in form, which is exactly the shape a GPU is built to chew through: thousands of cores running the same program over different data, at the same time. A compute dispatch is that same SIMD parallelism with the graphics pipeline stripped away entirely — no rasterizer deciding which invocations exist, just a grid of workgroups you name a size for, in full.
Each invocation figures out which piece of the overall problem is "its" piece using the global_invocation_id built-in — a three-component index computed from the workgroup's position in the dispatch grid and the invocation's position within its workgroup. A typical pattern reads one array element at that index, does something to it, and writes a result back, with no coordination needed between invocations because each one owns a distinct index.
Storage buffers: read-write memory for compute
Uniform buffers, from lesson 6, are read-only and small. Compute needs something a shader can both read and write, at a size that scales with real data — that's a storage buffer:
@group(0) @binding(0)
var<storage, read_write> data: array<f32>;var<storage, read_write> (versus var<uniform>) is the WGSL-level declaration of that difference: a storage buffer's contents can be mutated by the shader that's bound to it, and it can hold an unbounded array rather than a fixed small struct. Storage buffers are bound through bind groups exactly the same way uniform buffers are — a GPUBindGroupLayout entry declares buffer: { type: "storage" } instead of "uniform", and a GPUBindGroup supplies the actual GPUBuffer. Nothing about the bind-group machinery from lesson 6 changes; only the resource type and what the shader is allowed to do with it does.
A compute pass: the same encoder, a different kind of pass
Lesson 7 built the record-then-submit pattern around beginRenderPass. Compute reuses every part of it except the pass type:
const computePipeline = device.createComputePipeline({
layout: "auto",
compute: { module: shaderModule, entryPoint: "main" },
});
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(computePipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(Math.ceil(elementCount / 64));
pass.end();
device.queue.submit([encoder.finish()]);createComputePipeline is the compute analog of createRenderPipeline — a single shader stage instead of a vertex/fragment pair, validated and lowered to native code once, at creation, for the same reasons lesson 4 argued for render pipelines. beginComputePass, setPipeline, setBindGroup, end, finish, and submit are the literal same methods and the same recording model as the last lesson — nothing new to learn there, just a pass with dispatches inside it instead of draws.
Reading results back to JavaScript
A storage buffer written by a compute shader lives entirely in GPU memory; getting its contents back into JavaScript needs a copy and a map, the readback path lesson 3 covers in full:
const readbackBuffer = device.createBuffer({
size: dataBuffer.size,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
const copyEncoder = device.createCommandEncoder();
copyEncoder.copyBufferToBuffer(dataBuffer, 0, readbackBuffer, 0, dataBuffer.size);
device.queue.submit([copyEncoder.finish()]);
await readbackBuffer.mapAsync(GPUMapMode.READ);
const result = new Float32Array(readbackBuffer.getMappedRange().slice());
readbackBuffer.unmap();The storage buffer itself can't be mapped directly if it's also used as a compute target, so the pattern is: copy its contents into a second buffer created specifically with COPY_DST | MAP_READ usage, submit that copy, then mapAsync the second buffer once the GPU has finished writing to it. This is the same copy-then-map dance any GPU-to-CPU readback needs, compute included.
What this is actually for
None of this exists for graphics tricks. Real uses of compute shaders include particle and physics simulation (updating thousands of positions and velocities per frame, entirely on the GPU, with no per-particle CPU work), image and video processing (blurs, color grading, format conversion applied to every pixel in parallel), machine-learning inference (matrix multiplies are exactly the same shape as a compute dispatch), and data-parallel primitives like reductions, prefix sums, and parallel sorting — algorithms with no rendering involved that simply need "the same operation, applied to a huge amount of data, fast."
Workgroup size, briefly
@workgroup_size isn't a free parameter to set as large as possible. Invocations within one workgroup can share a small pool of fast on-chip memory (var<workgroup, ...> in WGSL) and synchronize with each other, which is useful for algorithms where nearby invocations need each other's intermediate results — but only invocations in the same workgroup get that. The GPU also runs a limited number of workgroups concurrently per compute unit, so a workgroup size that's too large or too small can leave hardware underused. A workgroup size of 64 or 256 is a common starting point; tuning it further is a real performance lever, but getting the parallelism working correctly at all matters far more than tuning it on a first pass.
The code: doubling an array, entirely on the GPU
@group(0) @binding(0)
var<storage, read_write> data: array<f32>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3u) {
let i = id.x;
if (i >= arrayLength(&data)) {
return;
}
data[i] = data[i] * 2.0;
}const bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.COMPUTE,
buffer: { type: "storage" },
},
],
});
const pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
compute: { module: shaderModule, entryPoint: "main" },
});
const bindGroup = device.createBindGroup({
layout: bindGroupLayout,
entries: [{ binding: 0, resource: { buffer: dataBuffer } }],
});
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(Math.ceil(elementCount / 64));
pass.end();
device.queue.submit([encoder.finish()]);Every element of data gets doubled, in parallel, across as many invocations as dispatchWorkgroups requested — no loop written anywhere in JavaScript, no triangles, no pixels, just a program applied to memory.
Coda: where compute actually lands today
WebGPU's browser support has been landing steadily — shipped in Chromium-based browsers, arriving in others — but "steadily" still means feature-detecting navigator.gpu and having a fallback matters in production, exactly as lesson 2 described. Three.js itself has a WebGPU renderer alongside its long-standing WebGL one, plus TSL (the Three.js Shading Language), a JavaScript-side way to author shaders that compiles down to WGSL for the WebGPU backend and GLSL for the WebGL one — an acknowledgment that both APIs will coexist for a while yet. What matters for everything you've learned in this module is that none of it was graphics-specific machinery wearing a compute costume: the device and queue (lesson 2), buffers (lesson 3), pipelines (lesson 4), WGSL (lesson 5), bind groups (lesson 6), and command encoders (lesson 7) are the same objects, doing the same jobs, whether the pass at the center is a render pass or a compute pass.
Synthesis: why the module went in this order
Zoom out. WebGPU exists (lesson 1) because WebGL's hidden state machine cost too much to validate per draw and couldn't compute at all. Fixing that needed an explicit device and a single queue (lesson 2) as the one channel to the GPU, explicit buffers (lesson 3) as the one way memory gets allocated, pipelines (lesson 4) to bake state once instead of per draw, WGSL (lesson 5) as the language those pipelines' shaders are written in, bind groups (lesson 6) to connect real resources to what a shader expects, and command encoders (lesson 7) to batch many commands into one cheap CPU→GPU handoff. Compute shaders are what all of that was really for — not a faster way to draw triangles, but a general parallel processor finally exposed to the web platform, using every object the previous seven lessons already taught you to build.
Go deeper
- MDN — GPUComputePipeline — Reference for createComputePipeline and the compute-specific pipeline descriptor this lesson's code used.
- web.dev — GPU compute for the web — A practical walkthrough of a first compute shader end to end, including workgroup sizing and readback, aimed at exactly this lesson's material.
- Surma — WebGPU (from a compute angle) — A deep dive that leads with compute rather than graphics, arguing (as this lesson does) that it's WebGPU's real reason to exist.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- What marks a WGSL function as a compute entry point, and what does @workgroup_size(64) fix about how that shader will run?
- Explain dispatchWorkgroups(nx, ny, nz): how many total invocations run, and how does each one figure out which piece of the work is its own?
- How does a storage buffer declared read_write differ from a uniform buffer in what a shader is allowed to do with it?
- Walk through a compute pass: which calls are identical to a render pass from lesson 7, and which two are different?
- Why can't you read a storage buffer's contents directly into JavaScript, and what two-step process does getting data back actually require?
- Give two real use cases for compute shaders that have nothing to do with drawing pixels, and explain what property of the workload makes each one a good fit for a GPU.
- In one sentence each, state what problem lessons 2 through 7 solved, and explain why compute shaders needed every one of those solutions already in place.