Game of Life
Conway’s Game of Life on a 128×128 grid that wraps at the edges. A compute pass applies the rules once per frame, reading the previous generation from one buffer and writing the next into another; a fullscreen triangle then colours each pixel from the newest generation without any intermediate texture. This is the one page in the series with a direct upstream counterpart, the WebGPU Samples gameOfLife sample (source): the same compute step, but the cells are displayed here from the fragment shader instead of as instanced quads.
; Game of Life: Conway's cellular automaton on a 128×128 toroidal grid. A; compute `(init …)` seeds the cell buffer once with a ~30% random alive; pattern; each frame a compute pass applies the rules from the previous; generation into the next (`:pool 2` ping-pong buffer, `:ping-pong` bind-group; entries with `:bind-groups-pool-offsets`, `(dispatch :workgroups [8 8 1])` over a 16×16; workgroup), and a fullscreen triangle reads the latest generation straight; from the storage buffer. Corresponds to the webgpu-samples `gameOfLife`; sample; the cells are drawn here without instanced quads.;; GRID_SIZE sizes the buffer only; `(define …)` values never reach WGSL, so; each shader restates the extent as a local `SIZE` constant. Keep them in step.
(define :name GRID_SIZE :value 128)
(buffer :name cellBuffer :size (* GRID_SIZE GRID_SIZE 4) :usage [storage] :pool 2)
(buffer :name uniforms :size 16 :usage [uniform copy-dst])
(queue :name writeUniforms (write-buffer :buffer uniforms :offset 0 :data pngine-inputs))
; Seed the cell buffer once with a ~30% random alive pattern.(init :name initCells :buffer cellBuffer :module initShader :workgroups [(ceil (/ (* GRID_SIZE GRID_SIZE) 64))])
(shader-module :name initShader :code """struct Cells { data: array<u32> }@binding(0) @group(0) var<storage, read_write> cells: Cells;
fn hash(n: u32) -> f32 { var x = n; x = ((x >> 16u) ^ x) * 0x45d9f3bu; x = ((x >> 16u) ^ x) * 0x45d9f3bu; x = (x >> 16u) ^ x; return f32(x) / f32(0xffffffffu);}
const SIZE: u32 = 128u;
@compute @workgroup_size(64)fn main(@builtin(global_invocation_id) id: vec3u) { let idx = id.x; if (idx >= SIZE * SIZE) { return; } // ~30% chance of being alive cells.data[idx] = select(0u, 1u, hash(idx) < 0.3);}""")
(shader-module :name stepShader :code """struct Cells { data: array<u32> }@binding(0) @group(0) var<storage, read> cellsIn: Cells;@binding(1) @group(0) var<storage, read_write> cellsOut: Cells;
const SIZE: u32 = 128u;
fn getCell(x: i32, y: i32) -> u32 { let wx = (x + i32(SIZE)) % i32(SIZE); let wy = (y + i32(SIZE)) % i32(SIZE); return cellsIn.data[u32(wy) * SIZE + u32(wx)];}
@compute @workgroup_size(16, 16)fn main(@builtin(global_invocation_id) id: vec3u) { if (id.x >= SIZE || id.y >= SIZE) { return; }
let x = i32(id.x); let y = i32(id.y); let idx = id.y * SIZE + id.x;
// Count neighbors (8-way) var neighbors = 0u; neighbors += getCell(x - 1, y - 1); neighbors += getCell(x, y - 1); neighbors += getCell(x + 1, y - 1); neighbors += getCell(x - 1, y); neighbors += getCell(x + 1, y); neighbors += getCell(x - 1, y + 1); neighbors += getCell(x, y + 1); neighbors += getCell(x + 1, y + 1);
let alive = cellsIn.data[idx];
// Conway's rules var newState = 0u; if (alive == 1u) { newState = select(0u, 1u, neighbors == 2u || neighbors == 3u); } else { newState = select(0u, 1u, neighbors == 3u); }
cellsOut.data[idx] = newState;}""")
(compute-pipeline :name stepPipeline :layout auto (compute :module stepShader :entry main))
(bind-group :name stepBindGroup :layout stepPipeline :group 0 :pool 2 (entry :binding 0 :buffer cellBuffer :ping-pong 0) (entry :binding 1 :buffer cellBuffer :ping-pong 1))
(compute-pass :name stepPass :pipeline stepPipeline :bind-groups [stepBindGroup] :bind-groups-pool-offsets [0] (dispatch :workgroups [8 8 1]))
(shader-module :name renderShader :code """struct Cells { data: array<u32> }@group(0) @binding(0) var<storage, read> cells: Cells;
struct Uniforms { time: f32, width: f32, height: f32, aspect: f32,}@group(0) @binding(1) var<uniform> u: Uniforms;
const SIZE: f32 = 128.0;
@vertexfn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4f { let x = f32(i & 1u) * 4.0 - 1.0; let y = f32((i >> 1u) & 1u) * 4.0 - 1.0; return vec4f(x, y, 0.0, 1.0);}
@fragmentfn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f { let cellX = u32(pos.x * SIZE / u.width); let cellY = u32(pos.y * SIZE / u.height); let idx = cellY * u32(SIZE) + cellX;
if (idx >= u32(SIZE * SIZE)) { return vec4f(0.1, 0.1, 0.15, 1.0); }
let alive = cells.data[idx]; let color = select(vec3f(0.1, 0.1, 0.15), vec3f(0.2, 0.8, 0.3), alive == 1u); return vec4f(color, 1.0);}""")
(render-pipeline :name renderPipeline :layout auto (vertex :module renderShader :entry vs) (fragment :module renderShader :entry fs (target :format preferred-canvas-format)))
(bind-group :name renderBindGroup :layout renderPipeline :group 0 :pool 2 (entry :binding 0 :buffer cellBuffer :ping-pong 1) (entry :binding 1 :buffer uniforms))
(render-pass :name drawPass (color-attachment :view context-current-texture :clear-value [0.1 0.1 0.15 1] :load-op clear :store-op store) :pipeline renderPipeline :bind-groups [renderBindGroup] :bind-groups-pool-offsets [0] (draw :vertex-count 3))
(frame :name main :init [initCells] :perform [writeUniforms stepPass drawPass])examples/samples/17_game_of_life.sjon in the pngine repository.
How it works
Section titled “How it works”The document is SJON, the S-expression format pngine compiles: each form below is one WebGPU resource or operation, and the shader text inside the forms is plain WGSL, WebGPU’s shading language.
The state: one buffer, two variants
Section titled “The state: one buffer, two variants”(buffer :name cellBuffer :size (* GRID_SIZE GRID_SIZE 4) :usage [storage] :pool 2)
declares the grid as one u32 per cell (128 × 128 × 4 bytes) with
STORAGE usage. :pool 2 is pngine’s ping-pong idiom: the compiler
allocates two GPU buffers under one name, and the passes below say which one
they mean. Nothing about the state ever crosses back to the CPU; the whole
simulation lives on the GPU.
(define :name GRID_SIZE :value 128) sizes the buffer only. A (define …)
value is available to SJON expressions but never reaches the WGSL text, so
each shader restates the extent as its own const SIZE; the two spellings
have to be kept in step by hand.
Seeding once: (init …)
Section titled “Seeding once: (init …)”(init :name initCells :buffer cellBuffer :module initShader :workgroups [(ceil (/ (* GRID_SIZE GRID_SIZE) 64))])
is sugar for a compute pipeline, a bind group and a compute pass that run
once, before the first frame. initShader fills each cell with 1 or 0 from
an integer hash of its index (about 30% alive). :workgroups takes a vector,
here one element holding an expression over the define: 16384 cells / 64
threads per workgroup = 256 workgroups.
(frame :name main :init [initCells] …) places it in the frame’s one-shot
init list.
Stepping: the compute pass
Section titled “Stepping: the compute pass”stepShader binds two views of the state: cellsIn as storage, read and
cellsOut as storage, read_write. getCell wraps coordinates with a
modulo so the grid is a torus, main counts the eight neighbours and applies
the rules with select. It runs @workgroup_size(16, 16), and the pass’s
(dispatch :workgroups [8 8 1]) covers 8 × 16 = 128 threads in each
dimension, exactly the grid.
The bind group carries the ping-pong: :pool 2 on
(bind-group :name stepBindGroup …) makes two variants, and the entries’
:ping-pong 0 / :ping-pong 1 say which buffer variant each binding takes
in each of them. :bind-groups-pool-offsets [0] on the pass picks the
variant for the current frame, and the runtime advances the offset every
frame, so the buffer written on one frame is the one read on the next.
Drawing: a fullscreen triangle reading storage
Section titled “Drawing: a fullscreen triangle reading storage”renderShader’s vertex stage builds the standard oversized triangle from
@builtin(vertex_index); no vertex buffer, (draw :vertex-count 3). Its
fragment stage maps the pixel’s @builtin(position) to a cell (using
width/height from the uniform buffer the runtime fills with
pngine-inputs, its 16-byte time/width/height/aspect source), reads that
cell straight from the storage buffer with storage, read, and picks green
or dark. renderBindGroup is also :pool 2 with :ping-pong 1, so it
reads the generation the step just wrote.
The frame
Section titled “The frame”(frame :name main :init [initCells] :perform [writeUniforms stepPass drawPass])
is the per-frame program: write the uniforms, step, draw. Every pass in
:perform runs once per animation frame, in order.
In the specifications
Section titled “In the specifications”| What the sample uses | WebGPU | WGSL |
|---|---|---|
| A storage buffer read by compute and fragment stages | GPUBufferUsage.STORAGE, buffer creation |
storage address space, access modes |
| Compute pipeline and dispatch | compute pipelines, dispatchWorkgroups() |
@workgroup_size, global_invocation_id, compute shaders and workgroups |
| Bind groups from an auto layout | layout: "auto", getBindGroupLayout(), bind group creation |
@group / @binding, resource interface |
| Fullscreen triangle from the vertex index | draw(), rasterization |
vertex_index, position |
| Uniform buffer written each frame | writeBuffer(), GPUBufferUsage.UNIFORM |
uniform address space, struct layout |
| Cell selection without branching | select |
Related
Section titled “Related”- Diffusion, Wave simulation and Fluid simulation reuse this exact structure with continuous fields.
- Forms:
(buffer …)(:pool),(bind-group …)(:ping-pong),(compute-pass …),(frame …),(init …). - Upstream: WebGPU Samples gameOfLife.