Everything we've built in the shader arc so far runs in one place: the fragment shader. One program that runs once per pixel, computes a color, outputs it. Every technique -- noise, SDFs, raymarching, fractals, post-processing, textures -- all fragment shader work. And it's powerful. We just built a complete generative artwork in episode 45 using nothing but fragment shader techniques.
But the fragment shader has a fundamental limitation. Each pixel runs in complete isolation. It can't talk to neighboring pixels. It can't write to arbitrary memory locations. It can't say "hey pixel at (300, 200), what's your velocity?" The only way to share information between pixels is through textures -- render to a texture in one pass, read it in the next. We did this with feedback loops in episode 36 and texture ping-pong in episode 44. It works, but it's a hack. You're encoding arbitrary data (particle positions, velocities, forces) into color channels and hoping the precision holds up.
There's a better way. Compute shaders are GPU programs that aren't tied to pixels or vertices at all. They read from buffers, write to buffers, and can do whatever you want. General-purpose computation on the GPU. The kind of thing that makes million-particle systems possible.
Today we're crossing from "shader art" into "GPU computing for creative coding." The fragment shader is still our rendering tool -- we'll always need it to put pixels on screen. But the simulation, the physics, the particle updates? That's compute territory now.
Let me make the limitation concrete. Say you want a particle system with 100,000 particles, each with position (x, y), velocity (vx, vy), and a life timer. On CPU, you'd have an array of particle objects and loop through them every frame. At 100k particles, that loop takes maybe 3-4ms on a fast CPU. Tight, but doable at 60fps.
At 500k particles? 15-20ms. Frame budget blown. At a million? Forget it.
The GPU has thousands of cores running in parallel. If each core handles one particle, a million particles is trivial -- the GPU doesn't loop through them sequentially, it processes them all simultaneously. But how do you get particle data onto the GPU and run custom update logic on it?
Option 1: data textures (what we did in episode 44). Encode particle positions as pixel colors in a texture. Use a fragment shader pass to read positions, apply physics, write new positions. Another fragment shader pass reads positions and renders the particles. It works. People have built impressive particle systems this way. But:
Option 2: compute shaders. The GPU runs your code on arbitrary data in storage buffers. Each work item reads and writes to any index in the buffer. Full 32-bit float precision. Shared memory for workgroup-local communication. Atomic operations for synchronization. And no framebuffer overhead -- you dispatch the compute work, it runs, done.
The catch: compute shaders aren't available in WebGL. You need WebGPU.
WebGPU is the successor to WebGL. Where WebGL was a thin wrapper around OpenGL ES (designed in the 2000s for mobile phones), WebGPU is a modern API designed around how GPUs actually work today. It exposes compute shaders, better resource management, explicit synchronization, and a new shading language called WGSL (WebGPU Shading Language).
Browser support as of 2026: Chrome and Edge have had it stable since 2023. Firefox has it enabled by default since late 2024. Safari has experimental support behind a flag. For creative coding projects where you control the runtime, this is more than enough.
WGSL looks different from GLSL but the concepts are the same. Variables, functions, math operations, vector types. The biggest differences are syntactic:
// GLSL
vec3 color = vec3(1.0, 0.0, 0.0);
float x = dot(a, b);
vec2 uv = gl_FragCoord.xy / u_resolution;
// WGSL equivalent
var color: vec3f = vec3f(1.0, 0.0, 0.0);
let x: f32 = dot(a, b);
// (no built-in gl_FragCoord -- vertex output feeds the fragment stage)
var is mutable, let is immutable. Types are explicit (f32, u32, vec3f, vec4f). Attributes use @ syntax (@location(0), @binding(0), @workgroup_size(64)). Function syntax is Rust-like: fn name(param: type) -> return_type { }.
I'm not going to teach the full WebGPU API here -- that's a series of its own. What I want to show you is the compute shader concept and how it applies to creative coding particle systems. We'll set up enough WebGPU to run a compute shader, then build something visual with it.
A compute shader is a function that runs N times in parallel on the GPU. You decide what N is when you dispatch it. Each invocation gets a unique ID (its index in the dispatch), reads from input buffers, does computation, and writes to output buffers.
@group(0) @binding(0) var<storage, read> input: array<vec4f>;
@group(0) @binding(1) var<storage, read_write> output: array<vec4f>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3u) {
let i = id.x;
let particle = input[i];
// update position by velocity
var pos = particle.xy + particle.zw * 0.016;
// write back
output[i] = vec4f(pos, particle.zw);
}
That's the whole shader. Each particle is a vec4f: x, y, velocity_x, velocity_y. The shader reads the particle at its index, adds velocity * deltaTime to position, writes the result. If you dispatch this with N = 1,000,000, a million particles get updated in one call. The GPU runs 64 of them per workgroup (that's what @workgroup_size(64) means) and schedules as many workgroups as needed.
The @builtin(global_invocation_id) gives each invocation its unique 3D index. For a 1D particle array, we only use id.x. For a 2D grid simulation, you'd use id.x and id.y. The workgroup size is a hint to the GPU about how to batch the work -- 64 is a common choice because most GPUs have wavefronts/warps of 32 or 64 threads.
The JavaScript boilerplate for WebGPU is more verbose than WebGL but more explicit about what's happening. Here's the minimal setup to run a compute shader:
// 1. get GPU device
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
// 2. create the compute shader module
const shaderCode = `
@group(0) @binding(0) var particles: array;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3u) {
let i = id.x;
if (i >= arrayLength(&particles)) { return; }
var p = particles[i];
// simple gravity toward center
let center = vec2f(0.5, 0.5);
let dir = center - p.xy;
let dist = length(dir);
// acceleration toward center
p.z += dir.x / (dist + 0.01) * 0.0001;
p.w += dir.y / (dist + 0.01) * 0.0001;
// damping
p.z *= 0.999;
p.w *= 0.999;
// integrate position
p.x += p.z;
p.y += p.w;
particles[i] = p;
}
`;
const shaderModule = device.createShaderModule({ code: shaderCode });
// 3. create the particle buffer
const numParticles = 100000;
const particleData = new Float32Array(numParticles * 4);
for (let i = 0; i < numParticles; i++) {
particleData[i * 4 + 0] = Math.random(); // x
particleData[i * 4 + 1] = Math.random(); // y
particleData[i * 4 + 2] = (Math.random() - 0.5) * 0.01; // vx
particleData[i * 4 + 3] = (Math.random() - 0.5) * 0.01; // vy
}
const particleBuffer = device.createBuffer({
size: particleData.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
mappedAtCreation: true,
});
new Float32Array(particleBuffer.getMappedRange()).set(particleData);
particleBuffer.unmap();
// 4. create pipeline and bind group
const pipeline = device.createComputePipeline({
layout: 'auto',
compute: {
module: shaderModule,
entryPoint: 'main',
},
});
const bindGroup = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [{
binding: 0,
resource: { buffer: particleBuffer },
}],
});
// 5. dispatch compute
function update() {
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(Math.ceil(numParticles / 64));
pass.end();
device.queue.submit([encoder.finish()]);
}
Five steps: get the device, write the shader, create a buffer with particle data, create the pipeline and bind group, dispatch. The dispatchWorkgroups(Math.ceil(numParticles / 64)) calculates how many workgroups of 64 threads we need to cover all particles.
The buffer usage flags are important. GPUBufferUsage.STORAGE means the compute shader can read/write it. GPUBufferUsage.COPY_SRC means we can copy from it (to read results back or to use it in a render pipeline). If you also want to render the particles, you'd add GPUBufferUsage.VERTEX so the render pipeline can read positions from it.
The compute shader updates particle positions. Now we need to draw them. In WebGPU, you create a render pipeline that reads from the same buffer the compute shader wrote to. The vertex shader reads each particle's position and outputs a point:
// vertex shader
struct VertexOutput {
@builtin(position) pos: vec4f,
@location(0) velocity: f32,
};
@vertex
fn vs_main(@location(0) particle: vec4f) -> VertexOutput {
var out: VertexOutput;
// map 0-1 range to clip space (-1 to 1)
let clipPos = particle.xy * 2.0 - 1.0;
out.pos = vec4f(clipPos, 0.0, 1.0);
out.velocity = length(particle.zw);
return out;
}
// fragment shader
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4f {
// color by velocity: slow = blue, fast = orange
let t = clamp(in.velocity * 100.0, 0.0, 1.0);
let color = mix(vec3f(0.1, 0.3, 0.8), vec3f(1.0, 0.5, 0.1), t);
return vec4f(color, 0.7);
}
The render pipeline reads the particle buffer as vertex data. Each particle is one vertex, rendered as a point. The velocity magnitude drives the color -- slow-moving particles are blue, fast ones are orange. With 100,000 points drawn with alpha blending, dense clusters glow brighter while scattered particles are faint. The visual result looks like a galaxy of stars spiraling around a gravitational center.
The JavaScript side for the render pass:
const renderPipeline = device.createRenderPipeline({
layout: 'auto',
vertex: {
module: renderShaderModule,
entryPoint: 'vs_main',
buffers: [{
arrayStride: 16, // 4 floats * 4 bytes
attributes: [{
shaderLocation: 0,
offset: 0,
format: 'float32x4',
}],
}],
},
fragment: {
module: renderShaderModule,
entryPoint: 'fs_main',
targets: [{
format: navigator.gpu.getPreferredCanvasFormat(),
blend: {
color: {
srcFactor: 'src-alpha',
dstFactor: 'one', // additive blending
operation: 'add',
},
alpha: { srcFactor: 'one', dstFactor: 'one', operation: 'add' },
},
}],
},
primitive: { topology: 'point-list' },
});
The topology: 'point-list' tells the GPU each vertex is an independent point (not triangles or lines). The blend mode is additive -- overlapping particles add their brightness together rather than occluding each other. This is the standard approach for particle rendering. Dense regions glow, sparse regions are dim.
The full loop runs compute then render every frame:
function frame() {
// run compute pass (update particles)
const encoder = device.createCommandEncoder();
const computePass = encoder.beginComputePass();
computePass.setPipeline(computePipeline);
computePass.setBindGroup(0, computeBindGroup);
computePass.dispatchWorkgroups(Math.ceil(numParticles / 64));
computePass.end();
// run render pass (draw particles)
const renderPass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
clearValue: { r: 0.02, g: 0.02, b: 0.04, a: 1.0 },
loadOp: 'clear',
storeOp: 'store',
}],
});
renderPass.setPipeline(renderPipeline);
renderPass.setVertexBuffer(0, particleBuffer);
renderPass.draw(numParticles);
renderPass.end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
}
Both passes go in the same command encoder, submitted as one batch. The GPU executes compute first, then render, automatically -- it knows the render pass reads from the buffer the compute pass writes. No manual synchronization needed. This is one of the big wins over the fragment-shader-as-compute hack: the driver handles the dependency tracking for you.
Change numParticles from 100,000 to 1,000,000 and the compute time barely changes. GPUs are built for this. The render pass might slow down (a million points is a lot of rasterization and blending), but the compute itself scales beautifully.
A single gravity point is nice but boring. Let's add multiple gravity wells that move over time. The compute shader gets a uniform buffer with well positions:
struct SimParams {
numWells: u32,
deltaTime: f32,
damping: f32,
pad: f32,
};
struct GravityWell {
pos: vec2f,
strength: f32,
pad: f32,
};
@group(0) @binding(0) var<storage, read_write> particles: array<vec4f>;
@group(0) @binding(1) var<uniform> params: SimParams;
@group(0) @binding(2) var<storage, read> wells: array<GravityWell>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3u) {
let i = id.x;
if (i >= arrayLength(&particles)) { return; }
var p = particles[i];
var acc = vec2f(0.0, 0.0);
// accumulate forces from all gravity wells
for (var w: u32 = 0; w < params.numWells; w++) {
let well = wells[w];
let dir = well.pos - p.xy;
let dist = max(length(dir), 0.01);
acc += normalize(dir) * well.strength / (dist * dist);
}
// integrate
p.z += acc.x * params.deltaTime;
p.w += acc.y * params.deltaTime;
p.z *= params.damping;
p.w *= params.damping;
p.x += p.z * params.deltaTime;
p.y += p.w * params.deltaTime;
// wrap around edges
p.x = fract(p.x);
p.y = fract(p.y);
particles[i] = p;
}
Three bindings now: the particle buffer, simulation parameters as a uniform, and an array of gravity wells as a read-only storage buffer. The shader loops through all wells and accumulates the gravitational acceleration. Each well pulls particles toward it with inverse-square falloff.
On the JavaScript side, update the well positions every frame to make them orbit or follow the mouse:
// mouse tracking
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
wellData[0] = e.clientX / rect.width; // well 0 x
wellData[1] = e.clientY / rect.height; // well 0 y
});
// orbiting wells
function updateWells(time) {
// well 0: mouse position (set by event listener)
wellData[2] = 0.5; // strength
// well 1: orbiting
wellData[4] = 0.5 + 0.3 * Math.sin(time * 0.7);
wellData[5] = 0.5 + 0.3 * Math.cos(time * 0.5);
wellData[6] = 0.3;
// well 2: orbiting opposite
wellData[8] = 0.5 - 0.2 * Math.cos(time * 0.4);
wellData[9] = 0.5 + 0.2 * Math.sin(time * 0.6);
wellData[10] = -0.2; // negative = repulsive!
device.queue.writeBuffer(wellBuffer, 0, wellData);
}
Negative strength makes a well repulsive -- particles get pushed away instead of attracted. Combine attractors and repulsors and you get complex flow patterns. Particles stream toward the attractors, get flung outward by the repulsor, curve back around, stream in again. With additive blending and velocity-based coloring, it looks like luminous fluid dynamics.
The mouse-tracking well makes it interactive. Move your cursor and a million particles chase it. The responsiveness is instantaneous because the GPU processes every particle in parallel. Try doing that with a JavaScript forEach loop :-)
Here's where compute shaders genuinely do things fragment shaders can't. Inter-particle forces -- each particle needs to know about nearby particles. Gravity between all pairs, collision avoidance, flocking behavior.
The naive approach: every particle checks every other particle. That's O(n^2). At 100k particles that's 10 billion distance calculations per frame. Even on a GPU, that's too slow.
The practical approach: spatial hashing. Divide the space into a grid of cells. Each particle belongs to one cell based on its position. When computing forces, a particle only checks particles in its own cell and the 8 adjacent cells. If the grid is fine enough that each cell contains ~10 particles, you've gone from checking 100,000 neighbors to checking ~90.
Building a spatial hash on the GPU is where compute shaders really flex. You need three passes:
All three can be done in compute shaders using atomic operations. The fragment shader can't do this -- it has no atomics and can't write to arbitrary buffer positions.
I won't show the full spatial hash implementation here because it's genuinely complex (the prefix sum alone is a classic parallel algorithms problem). But conceptually:
// simplified neighbor interaction
@compute @workgroup_size(64)
fn forces(@builtin(global_invocation_id) id: vec3u) {
let i = id.x;
if (i >= arrayLength(&particles)) { return; }
let myPos = particles[i].xy;
var force = vec2f(0.0, 0.0);
// get cell index for this particle
let cellX = u32(myPos.x * f32(gridWidth));
let cellY = u32(myPos.y * f32(gridHeight));
// check 3x3 neighborhood
for (var dy: i32 = -1; dy <= 1; dy++) {
for (var dx: i32 = -1; dx <= 1; dx++) {
let nx = i32(cellX) + dx;
let ny = i32(cellY) + dy;
if (nx < 0 || ny < 0) { continue; }
if (nx >= i32(gridWidth) || ny >= i32(gridHeight)) { continue; }
let cellIdx = u32(ny) * gridWidth + u32(nx);
let start = cellStarts[cellIdx];
let end = cellStarts[cellIdx + 1];
for (var j = start; j < end; j++) {
if (j == i) { continue; } // skip self
let other = particles[j].xy;
let diff = other - myPos;
let dist = length(diff);
if (dist < interactionRadius && dist > 0.001) {
// repulsion at close range, attraction at mid range
let f = (interactionRadius - dist) / interactionRadius;
force -= normalize(diff) * f * 0.001;
}
}
}
}
// store force for integration step
forces[i] = vec4f(force, 0.0, 0.0);
}
With the spatial hash, 100k particles with inter-particle repulsion runs at 60fps. Without it, you'd be waiting seconds per frame. The spatial hash is the enabling data structure -- and it fundamentally requires compute shader capabilities (atomic counters, arbitrary buffer writes, synchronization barriers).
N-body gravity is the visually stunning version. Every particle attracts every other particle with gravitational force. Normally O(n^2), but with compute shaders you can brute-force a surprisingly large N because the GPU is doing thousands of calculations in parallel.
For N under ~10,000, you can just loop through all pairs in the compute shader. It's O(n^2) but each iteration is trivially parallel:
@compute @workgroup_size(64)
fn nbody(@builtin(global_invocation_id) id: vec3u) {
let i = id.x;
if (i >= arrayLength(&particles)) { return; }
let myPos = particles[i].xy;
var acc = vec2f(0.0);
for (var j: u32 = 0; j < arrayLength(&particles); j++) {
if (j == i) { continue; }
let otherPos = particles[j].xy;
let diff = otherPos - myPos;
let distSq = dot(diff, diff) + 0.0001; // softening
acc += diff / (distSq * sqrt(distSq));
}
// scale and integrate
let vel = particles[i].zw + acc * 0.00001;
let pos = myPos + vel;
particles[i] = vec4f(pos, vel * 0.9995);
}
The + 0.0001 softening factor prevents the force from going to infinity when two particles are very close (which would fling them to absurd velocities). It's a standard trick in N-body simulations.
At 5,000 particles with brute-force N-body, you get these incredible gravitational spirals. Clusters form, orbit each other, merge, fling off streamers of particles. It genuinley looks like a galaxy forming. With velocity-based coloring (slow particles in the core glow amber, fast particles in the spiral arms glow blue-white), it's one of the most beautiful things you can make with code.
For larger N (50k+), you'd use the Barnes-Hut algorithm -- a tree structure that approximates distant particles as a single mass. That's a whole implementation project, but the concept is: nearby particles compute exact forces, distant particles are clustered and approximated. It reduces O(n^2) to O(n log n).
If you don't want to use WebGPU (maybe you need wider browser support, or you're working in an environment without WebGPU), WebGL 2 has transform feedback. It's not a compute shader -- it uses the vertex shader -- but it lets you write vertex outputs back to a buffer instead of sending them to the rasterizer.
The idea: each particle is a vertex. The vertex shader reads the particle's current state (position, velocity), applies physics, and outputs the new state. Transform feedback captures that output into a buffer. Next frame, you swap buffers -- the output becomes the input.
// vertex shader for particle update (WebGL 2)
#version 300 es
precision highp float;
in vec4 a_particle; // x, y, vx, vy
out vec4 v_particle; // transform feedback output
uniform vec2 u_gravity;
uniform float u_damping;
void main() {
vec2 pos = a_particle.xy;
vec2 vel = a_particle.zw;
// apply gravity
vec2 dir = u_gravity - pos;
float dist = length(dir);
vel += normalize(dir) * 0.0001 / (dist + 0.01);
// damping
vel *= u_damping;
// integrate
pos += vel;
// wrap
pos = fract(pos);
v_particle = vec4(pos, vel);
// we don't actually rasterize anything
gl_Position = vec4(0.0);
gl_PointSize = 1.0;
}
And the JavaScript side:
// create transform feedback object
const tf = gl.createTransformFeedback();
gl.bindTransformFeedback(gl.TRANSFORM_FEEDBACK, tf);
// specify which varyings to capture
gl.transformFeedbackVaryings(
updateProgram,
['v_particle'],
gl.SEPARATE_ATTRIBS
);
gl.linkProgram(updateProgram);
// in the update loop:
gl.enable(gl.RASTERIZER_DISCARD); // don't draw, just compute
gl.bindBufferBase(gl.TRANSFORM_FEEDBACK_BUFFER, 0, outputBuffer);
gl.beginTransformFeedback(gl.POINTS);
gl.drawArrays(gl.POINTS, 0, numParticles);
gl.endTransformFeedback();
gl.disable(gl.RASTERIZER_DISCARD);
// swap buffers for next frame
[inputBuffer, outputBuffer] = [outputBuffer, inputBuffer];
gl.RASTERIZER_DISCARD is the key -- it tells WebGL "don't rasterize, just run the vertex shader and capture the outputs." The vertex shader becomes a compute kernel. You can't do inter-particle forces this way (each vertex runs independently, same limitation as the fragment shader), but for simple per-particle physics it works great. And it runs on every browser that supports WebGL 2, which is basically everything.
Transform feedback handles 500k particles at 60fps easily. The buffer swap pattern (ping-pong, same as our texture feedback from episode 36) keeps the data entirely on the GPU. No CPU readback, no JavaScript array processing.
Let's put some numbers on this so you know what to expect:
Fragment shader as compute (WebGL 1/2):
Transform feedback (WebGL 2):
Compute shaders (WebGPU):
The jump from fragment-shader-as-compute to real compute is like going from "I can kinda do particles" to "I can simulate fluid dynamics." The visual difference between 50k particles and 1M particles is massive. Dense particle systems create volumetric, fluid-like appearance that sparse systems can't match.
Build this. It's the gateway drug to GPU compute for creative coders:
If you want to go further:
The point isn't to build the perfect particle system. It's to feel the difference between "I'm hacking around fragment shader limitations" and "the GPU is doing exactly what I asked." Once you've run a compute shader on a million particles and seen them all react to your mouse in real time, you don't go back.
This episode covered the concept and the setup. We touched on WGSL syntax, the compute dispatch model, storage buffers, the render pipeline for particles, gravity wells, inter-particle forces via spatial hashing, N-body gravity, and transform feedback as the WebGL 2 fallback.
All the shader techniques from the arc -- noise, color palettes, post-processing -- still apply when rendering. The compute shader handles simulation. The fragment shader handles rendering. They complement each other perfectly.
The upcoming episodes move into a different territory: emergent systems. Simulations where simple rules produce complex behavior. Cellular automata, where a grid of cells updates based on their neighbors. Flocking algorithms where autonomous agents create swarm patterns. Reaction-diffusion systems where chemicals interact to produce leopard spots and zebra stripes. These are simulations first, visuals second -- and the GPU compute concepts from today make them feasable at scales where the patterns really emerge.
But fragment shaders aren't going anywhere. They're still how we put pixels on screen. Everything from episodes 21-45 applies to every future project. We've just added a new tool to the kit: the ability to run arbitrary computation on the GPU, unconstrained by the pixel grid.
Sallukes! Thanks for reading.
X