Tutorial · Intermediate · ~45 min
GPU Game of Life in 50 Lines of GLSL
Moving the Game of Life automaton to the GPU unlocks million-cell grids at 60 FPS. Each pixel in a texture represents one cell. A fragment shader reads the texture, counts neighbours, and writes the next state — all in parallel across every GPU thread. Two textures alternate (ping-pong) so the shader reads from the old state and writes to the new.
1Create ping-pong render targets
import * as THREE from
'https://cdn.jsdelivr.net/npm/three@0.160/build/three.module.js';
const SIZE = 1024; // grid is SIZE × SIZE cells function makeRT() {
return new THREE.WebGLRenderTarget(SIZE, SIZE, { minFilter:
THREE.NearestFilter, magFilter: THREE.NearestFilter, format:
THREE.RGBAFormat, type: THREE.UnsignedByteType, wrapS:
THREE.RepeatWrapping, // toroidal wrapping wrapT:
THREE.RepeatWrapping, }); } // Two targets — we alternate read/write
each frame let rtA = makeRT(); let rtB = makeRT(); // Helper to swap
them function swap() { [rtA, rtB] = [rtB, rtA]; }
NearestFilter is critical — we must not blend texel
values when sampling neighbours. Bilinear would corrupt the binary
cell state.
2Seed the initial state
// Create a random-noise texture (each pixel R channel = alive/dead)
const data = new Uint8Array(SIZE * SIZE * 4); for (let i = 0; i <
SIZE * SIZE; i++) { const alive = Math.random() < 0.3 ? 255 : 0; //
30 % alive data[i * 4 + 0] = alive; // R = alive data[i * 4 + 3] =
255; // A = opaque } const seedTex = new THREE.DataTexture(data, SIZE,
SIZE, THREE.RGBAFormat); seedTex.needsUpdate = true; // Paint the seed
into rtA via a plain copy pass const copyMat = new
THREE.MeshBasicMaterial({ map: seedTex }); const quad = new
THREE.Mesh(new THREE.PlaneGeometry(2, 2), copyMat); const simCam = new
THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1); const simScene = new
THREE.Scene(); simScene.add(quad); renderer.setRenderTarget(rtA);
renderer.render(simScene, simCam); renderer.setRenderTarget(null);
3The Game-of-Life fragment shader
const simFrag = /* glsl */ ` precision highp float; uniform sampler2D
u_state; uniform vec2 u_res; // SIZE, SIZE varying vec2 vUv; float
cell(vec2 offset) { // Sample with wrapping — using RepeatWrapping on
the texture return texture2D(u_state, vUv + offset / u_res).r > 0.5
? 1.0 : 0.0; } void main() { float alive = cell(vec2(0.0)); // Count
the 8 neighbours float neighbours = cell(vec2(-1,-1)) +
cell(vec2(0,-1)) + cell(vec2(1,-1)) + cell(vec2(-1, 0)) + cell(vec2(1,
0)) + cell(vec2(-1, 1)) + cell(vec2(0, 1)) + cell(vec2(1, 1)); //
Conway rules float next; if (alive > 0.5) { next = (neighbours ==
2.0 || neighbours == 3.0) ? 1.0 : 0.0; } else { next = (neighbours ==
3.0) ? 1.0 : 0.0; } gl_FragColor = vec4(next, next, next, 1.0); } `;
const simMat = new THREE.ShaderMaterial({ uniforms: { u_state: {
value: null }, u_res: { value: new THREE.Vector2(SIZE, SIZE) }, },
vertexShader: ` varying vec2 vUv; void main() { vUv = uv; gl_Position
= vec4(position, 1.0); } `, fragmentShader: simFrag, }); quad.material
= simMat;
4Simulation pass in the render loop
function stepSimulation() { // READ from rtA, WRITE to rtB
simMat.uniforms.u_state.value = rtA.texture;
renderer.setRenderTarget(rtB); renderer.render(simScene, simCam);
renderer.setRenderTarget(null); // Now rtB has the new generation —
swap for next frame swap(); } function animate() {
requestAnimationFrame(animate); // Run several simulation steps per
visual frame for faster patterns for (let s = 0; s < 2; s++)
stepSimulation(); // Display the current state (rtA after swap = the
freshly written rtB) displayMat.uniforms.u_tex.value = rtA.texture;
renderer.render(displayScene, displayCam); }
Always read from one render target and write to the other. Swapping
pointers is O(1) and avoids expensive texture copies.
5Display pass and mouse interaction
// Display shader: map alive=white, dead=dark-green const displayFrag
= /* glsl */ ` precision highp float; uniform sampler2D u_tex; varying
vec2 vUv; void main() { float v = texture2D(u_tex, vUv).r; vec3 col =
mix(vec3(0.05, 0.07, 0.05), vec3(0.28, 0.93, 0.43), v); gl_FragColor =
vec4(col, 1.0); } `; // Mouse: paint live cells on click/drag
canvas.addEventListener('pointermove', e => { if (e.buttons === 0)
return; const uv = { x: e.offsetX / canvas.width, y: 1 - e.offsetY /
canvas.height }; // Write a 5×5 block of live cells around the mouse
position const px = Math.floor(uv.x * SIZE); const py =
Math.floor(uv.y * SIZE); const tex = rtA.texture; // For persistent
editing, use a DataTexture + readRenderTargetPixels approach // or
simply sample the state at that position in the shader using a u_brush
uniform console.info(`Paint at cell (${px}, ${py})`); });
For interactive painting, pass the brush position as a
vec2 uniform to the simulation shader and set cells
within a radius to alive — no CPU readback required.