Skip to content

Game of Life

Download PNG

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.

examples/samples/17_game_of_life.sjon
; 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;
@vertex
fn 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);
}
@fragment
fn 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])

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.

(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.

(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.

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.

(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.

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