Tutorial · Intermediate · ~45 min
WebGL · GLSL · Three.js · Ping-pong Textures

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.