Compute shaders — a pond the GPU simulates
Compute shaders — a pond the GPU simulates
Ripples spreading across a pond, simulated on the GPU, 65,000 points every step.
some coding · about 30 minutes · 8 steps
Follow this along inside the editor — the 🎓 Learn tab has the same steps and ticks each one off as your project starts to match it.
A compute shader is a program that runs on the GPU and draws nothing: it reads and writes buffers and textures, every pixel or particle at once. Here it simulates a pond. Raindrops land, ripples spread and fade, and the water is lit by its own slope, 65,536 points every step. The same thing in a Lua loop would take most of a frame; on the GPU it is a fraction of a millisecond.
The idea
You run it; it doesn't run itself. Your script calls compute.run with a
shader file, an entry point, how many invocations, and what to bind. The
engine runs it before the frame draws, so anything drawn that frame sees the
result.
The engine writes the boring part. Every buffer and texture you bind is
there in the shader under the name you bound it to, with params beside them.
You never count binding numbers.
What you need first
Some Lua, and the compute reference open in a tab for later. Shaders are WGSL, the language WebGPU uses; you'll see all of it you need here.
1. A pond to draw on
Add a Plane and name it Pond. Point the camera down at it, so it fills
most of the view. That is the whole scene; everything else happens on the GPU.
Done when: a node called Pond is in the scene.
2. Your first compute shader
Shaders live in shaders/, as .wgsl files. This one has one entry point,
paint, run once for every pixel of a picture.
@workgroup_size(8, 8)is how many invocations run together: 64, as an 8×8 tile. 64 is a good size on every GPU.idis which invocation this is: here, which pixel.pictureandparamsare not declared anywhere in the file. The engine declares them, from what the script binds.- The first line of the body bails out past the picture's edge. The engine rounds the number of tiles up, so the last ones hang off the side.
shaders/pond.wgsl
// shaders/pond.wgsl: one entry point, run for every pixel of the picture.
@compute @workgroup_size(8, 8)
fn paint(@builtin(global_invocation_id) id: vec3<u32>) {
let size = textureDimensions(picture);
if (id.x >= size.x || id.y >= size.y) { return; }
let uv = vec2<f32>(id.xy) / vec2<f32>(size);
let wave = 0.5 + 0.5 * sin(uv.x * 12.0 + params.time * params.speed);
let water = mix(vec3<f32>(0.02, 0.12, 0.25), vec3<f32>(0.3, 0.7, 1.0), wave * uv.y);
textureStore(picture, vec2<i32>(id.xy), vec4<f32>(water, 1.0));
}
3. Run it from a script
Attach a new script, pond, to Pond, and press Play. The pond fills with
moving bands of blue.
gpu.texturemakes a picture on the GPU. Itsname(gpu:1) works anywhere a texture path does, sosetMaterialputs it on the plane.compute.runrunspaintoversizeinvocations, one per pixel, withpicturebound to the texture andparams.speedset to 2.params.timeis always there: seconds of play.
Change speed, save, and watch it change. Change the shader and save it: it
reloads while you play.
scripts/pond.lua
-- The pond: a picture the GPU paints, every frame.
local SIZE = 256
local picture
function start(node)
picture = gpu.texture{ w = SIZE, h = SIZE }
node:setMaterial{ texture = picture.name }
end
function update(node, dt)
compute.run("shaders/pond.wgsl", "paint", {
size = { SIZE, SIZE },
bind = { picture = picture },
params = { speed = 2 },
})
end
Done when: Pond runs pond.lua.
4. Ripples need memory
A ripple is a height that spreads to its neighbours, step by step, so the
shader needs last step's heights to make this step's. That memory goes in
buffers: arrays on the GPU, here one vec2 per point of the pond, this
step's height and the last one.
A shader can't safely read a buffer while it writes the same one: one
invocation would read a neighbour another had already changed. So there are
two, now and later. ripple reads now and writes later, and the
script swaps them after each step. This is called ping-pong.
paint now lights the water by its slope, read from now. Copy this over
your pond.wgsl.
cell is an ordinary function. It can use params because each run gives a
helper the same bindings as the entry point that calls it.
shaders/pond.wgsl
// shaders/pond.wgsl: ripples on a pond.
//
// `now` holds each point's height this step (x) and the step before (y).
// `ripple` reads `now` and writes `later`; the script swaps the two.
fn cell(x: i32, y: i32) -> u32 {
let n = i32(params.size);
return u32(clamp(y, 0, n - 1) * n + clamp(x, 0, n - 1));
}
@compute @workgroup_size(8, 8)
fn ripple(@builtin(global_invocation_id) id: vec3<u32>) {
let n = u32(params.size);
if (id.x >= n || id.y >= n) { return; }
let x = i32(id.x);
let y = i32(id.y);
let here = now[cell(x, y)];
let around = now[cell(x - 1, y)].x + now[cell(x + 1, y)].x + now[cell(x, y - 1)].x + now[cell(x, y + 1)].x;
// The classic ripple: half the neighbours, less where it was a step ago.
var h = (around * 0.5 - here.y) * params.damping;
// A raindrop, when the script drops one.
let d = vec2<f32>(f32(x), f32(y)) - vec2<f32>(params.dropX, params.dropY);
if (params.drop > 0.5 && dot(d, d) < 9.0) {
h = 1.0;
}
later[cell(x, y)] = vec2<f32>(h, here.x);
}
@compute @workgroup_size(8, 8)
fn paint(@builtin(global_invocation_id) id: vec3<u32>) {
let n = u32(params.size);
if (id.x >= n || id.y >= n) { return; }
let x = i32(id.x);
let y = i32(id.y);
// Lit by its slope, so a ripple reads as a ripple.
let slope = vec2<f32>(now[cell(x + 1, y)].x - now[cell(x - 1, y)].x, now[cell(x, y + 1)].x - now[cell(x, y - 1)].x);
let light = clamp(0.5 + dot(slope, vec2<f32>(0.6, 0.8)) * 3.0, 0.0, 1.0);
let water = mix(vec3<f32>(0.02, 0.12, 0.25), vec3<f32>(0.55, 0.85, 1.0), light);
textureStore(picture, vec2<i32>(id.xy), vec4<f32>(water, 1.0));
}
5. Swap the buffers, drop the rain
Copy this over pond.lua and press Play: raindrops land, and their rings
spread and fade.
- Two
gpu.buffers ofSIZE × SIZEvec2s, zeroed: a still pond. - The ripple runs in
fixedUpdate, so it steps at the same rate on every machine and the ripples spread at the same speed however fast the frames come. Painting isupdate's job, once a frame. - A drop is three numbers in
params: whether to drop, and where. The script picks them; the shader does the splash. now, later = later, nowis the ping-pong. Calls run in the order you make them, each after the last finished, sopaintsees the step that just ran.
Try damping = 0.995 for a glassier pond, or 0.95 for treacle.
scripts/pond.lua
-- The pond: ripples simulated on the GPU, raindrops dropped from here.
local SIZE = 256
local now, later, picture
local untilDrop = 0
function start(node)
-- Two buffers of heights: one is read while the other is written.
now = gpu.buffer{ count = SIZE * SIZE, type = "vec2" }
later = gpu.buffer{ count = SIZE * SIZE, type = "vec2" }
picture = gpu.texture{ w = SIZE, h = SIZE }
node:setMaterial{ texture = picture.name }
end
-- The simulation steps at a fixed rate, so ripples spread at the same
-- speed however fast the frames come.
function fixedUpdate(node, dt)
local drop, x, y = 0, 0, 0
untilDrop = untilDrop - dt
if untilDrop <= 0 then
untilDrop = 0.2 + math.random() * 0.6
drop, x, y = 1, math.random(8, SIZE - 8), math.random(8, SIZE - 8)
end
compute.run("shaders/pond.wgsl", "ripple", {
size = { SIZE, SIZE },
bind = { now = now, later = later },
params = { size = SIZE, damping = 0.985, drop = drop, dropX = x, dropY = y },
})
now, later = later, now
end
function update(node, dt)
compute.run("shaders/pond.wgsl", "paint", {
size = { SIZE, SIZE },
bind = { now = now, picture = picture },
params = { size = SIZE },
})
end
Done when: pond.lua swaps its two buffers.
6. Count on the GPU
How much of the pond is moving? Every pixel knows its own answer, and 65,536 of them want to add to one total at the same moment. An ordinary write would lose most of them. An atomic add can't: each one lands.
paint now adds one to moving[0] for every pixel whose height is not close
to still. Copy this over pond.wgsl; the script in the next step binds
moving.
shaders/pond.wgsl
// shaders/pond.wgsl: ripples on a pond, and how much of it is moving.
//
// `now` holds each point's height this step (x) and the step before (y).
// `ripple` reads `now` and writes `later`; the script swaps the two.
fn cell(x: i32, y: i32) -> u32 {
let n = i32(params.size);
return u32(clamp(y, 0, n - 1) * n + clamp(x, 0, n - 1));
}
@compute @workgroup_size(8, 8)
fn ripple(@builtin(global_invocation_id) id: vec3<u32>) {
let n = u32(params.size);
if (id.x >= n || id.y >= n) { return; }
let x = i32(id.x);
let y = i32(id.y);
let here = now[cell(x, y)];
let around = now[cell(x - 1, y)].x + now[cell(x + 1, y)].x + now[cell(x, y - 1)].x + now[cell(x, y + 1)].x;
var h = (around * 0.5 - here.y) * params.damping;
let d = vec2<f32>(f32(x), f32(y)) - vec2<f32>(params.dropX, params.dropY);
if (params.drop > 0.5 && dot(d, d) < 9.0) {
h = 1.0;
}
later[cell(x, y)] = vec2<f32>(h, here.x);
}
@compute @workgroup_size(8, 8)
fn paint(@builtin(global_invocation_id) id: vec3<u32>) {
let n = u32(params.size);
if (id.x >= n || id.y >= n) { return; }
let x = i32(id.x);
let y = i32(id.y);
let slope = vec2<f32>(now[cell(x + 1, y)].x - now[cell(x - 1, y)].x, now[cell(x, y + 1)].x - now[cell(x, y - 1)].x);
let light = clamp(0.5 + dot(slope, vec2<f32>(0.6, 0.8)) * 3.0, 0.0, 1.0);
let water = mix(vec3<f32>(0.02, 0.12, 0.25), vec3<f32>(0.55, 0.85, 1.0), light);
textureStore(picture, vec2<i32>(id.xy), vec4<f32>(water, 1.0));
// Every pixel that is moving adds one, all of them at once.
if (abs(now[cell(x, y)].x) > 0.02) {
atomicAdd(&moving[0], 1u);
}
}
7. Read the answer back
Copy this over pond.lua and press Play. Every two seconds the Console says
how much of the pond is moving.
type = "atomic"makes a buffer of numbers shaders add to withatomicAdd. From Lua it reads and writes like any whole number.moving:write({ 0 })zeroes it before eachpaint. Writes and runs keep the order you made them in.moving:read(fn)hands the numbers back a frame or two later. The frame never waits for the GPU: that is the price of reading back, and why it is a callback rather than an answer.
scripts/pond.lua
-- The pond: ripples simulated on the GPU, and how busy it is.
local SIZE = 256
local now, later, picture, moving
local untilDrop = 0
local untilCount = 0
function start(node)
now = gpu.buffer{ count = SIZE * SIZE, type = "vec2" }
later = gpu.buffer{ count = SIZE * SIZE, type = "vec2" }
picture = gpu.texture{ w = SIZE, h = SIZE }
-- One number every invocation can add to at once.
moving = gpu.buffer{ count = 1, type = "atomic" }
node:setMaterial{ texture = picture.name }
end
function fixedUpdate(node, dt)
local drop, x, y = 0, 0, 0
untilDrop = untilDrop - dt
if untilDrop <= 0 then
untilDrop = 0.2 + math.random() * 0.6
drop, x, y = 1, math.random(8, SIZE - 8), math.random(8, SIZE - 8)
end
compute.run("shaders/pond.wgsl", "ripple", {
size = { SIZE, SIZE },
bind = { now = now, later = later },
params = { size = SIZE, damping = 0.985, drop = drop, dropX = x, dropY = y },
})
now, later = later, now
end
function update(node, dt)
moving:write({ 0 })
compute.run("shaders/pond.wgsl", "paint", {
size = { SIZE, SIZE },
bind = { now = now, picture = picture, moving = moving },
params = { size = SIZE },
})
untilCount = untilCount - dt
if untilCount <= 0 then
untilCount = 2
-- Answered a frame or two later, without the frame waiting for it.
moving:read(function(values)
log(string.format("%d%% of the pond is moving", values[1] * 100 // (SIZE * SIZE)))
end)
end
end
Done when: pond.lua makes an atomic buffer.
8. What it costs, and where it runs
Press the ⏱ button in the play toolbar while it plays: among the GPU's
passes, each run appears as compute ripple and compute paint, well under
a millisecond each at this size. A script can ask the same with
perf.gpuMs("compute paint").
- Size. At 256×256 the GPU barely notices. A 1024×1024 pond is 16 times the work and still small. Readbacks are the thing to ration: one a second is plenty for a number a player reads.
- Everywhere. Compute runs on Windows, macOS, Linux and in the browser.
The browser's limits are the tightest (
gpu.limits()says what a device allows), and a game that keeps within them runs everywhere. - Not for gameplay rules. GPUs differ slightly in their arithmetic and a dedicated server has none, so use compute for what players see, not for state a multiplayer match must agree on.
From here: particles a hundred thousand strong, falling sand, a heat map painted onto terrain. The compute reference has a particle swarm and the game of life to start from.