Reaction-Diffusion
Turing’s recipe for animal patterns.
Advanced / PDE / field
Two chemicals diffuse at different rates while one consumes the other. Alan Turing showed in 1952 that this imbalance alone can break a uniform field into spots, stripes, and labyrinths. The Gray-Scott system is the classic laboratory for that instability: change the feed and kill rates and a different animal appears.
Tagged: Gray-Scott, Turing patterns, morphogenesis, stability
Live simulation
Drag inside the field to inject reagent B. Keyboard: Space runs or pauses, . steps, r resets.
Speed
Drag inside the field to inject reagent B.
Mathematics
Gray-Scott models two reagents: u is fed in uniformly, v eats it and decays. Turing showed in 1952 that diffusion alone, acting at different rates on two chemicals, can destabilize a uniform state into persistent spatial pattern.
∂u/∂t = D_u ∇²u − u v² + f (1 − u)
∂v/∂t = D_v ∇²v + u v² − (k + f) v
discrete Laplacian: −1·center + 0.2·orthogonal + 0.05·diagonal
The feed f and kill k are coordinates in a phase diagram: the same equations produce dividing cells, self-replicating spots, winding worms, or coral, and the borders between those regimes are sharp. The preset menu jumps to known points; the sliders let you explore the borders where the interesting failures live.
How it works
u and v live in Float32Arrays updated with the classic weighted 9-point Laplacian on a torus, followed by clamping back into [0, 1]. Each tick runs many solver iterations because the interesting dynamics unfold faster than 60 Hz. The pointer injects v directly; draw a stroke and watch it colonize or die depending on where you are in the phase diagram.
The render maps 1 − u through the colormap, so reagent-rich regions glow and the background stays dark.
Implementation
src/engine/experiments/reaction-diffusion.ts · 213 lines · the module running above
TypeScript · fixed-timestep engine
| 1 | import { BaseExperiment } from "../core/base-experiment"; |
| 2 | import { MAGMA, sampleInto } from "../render/colormaps"; |
| 3 | import type { ParameterDef, PointerState, ThemeColors } from "../core/types"; |
| 4 | |
| 5 | /** |
| 6 | * Gray-Scott reaction-diffusion with the classic weighted Laplacian |
| 7 | * (-1 center, 0.2 orthogonal, 0.05 diagonal) on a torus. One tick applies |
| 8 | * a configurable number of solver iterations. |
| 9 | */ |
| 10 | export class ReactionDiffusionExperiment extends BaseExperiment { |
| 11 | readonly id = "reaction-diffusion"; |
| 12 | |
| 13 | private n = 96; |
| 14 | private offsetX = 0; |
| 15 | private offsetY = 0; |
| 16 | private drawSize = 0; |
| 17 | private u = new Float32Array(0); |
| 18 | private v = new Float32Array(0); |
| 19 | private u2 = new Float32Array(0); |
| 20 | private v2 = new Float32Array(0); |
| 21 | private image: ImageData | null = null; |
| 22 | private scratch: HTMLCanvasElement | null = null; |
| 23 | private iterations = 0; |
| 24 | private activity = 0; |
| 25 | private pointer = { x: 0, y: 0, down: false, inside: false }; |
| 26 | |
| 27 | private readonly Du = 1.0; |
| 28 | private readonly Dv = 0.5; |
| 29 | |
| 30 | protected params(): ParameterDef[] { |
| 31 | return [ |
| 32 | { |
| 33 | key: "resolution", |
| 34 | label: "Grid", |
| 35 | options: [ |
| 36 | { value: "72", label: "72 × 72" }, |
| 37 | { value: "96", label: "96 × 96" }, |
| 38 | { value: "128", label: "128 × 128" }, |
| 39 | ], |
| 40 | defaultValue: "96", |
| 41 | }, |
| 42 | { key: "feed", label: "Feed rate f", min: 0.01, max: 0.09, step: 0.001, defaultValue: 0.055 }, |
| 43 | { key: "kill", label: "Kill rate k", min: 0.03, max: 0.073, step: 0.001, defaultValue: 0.062 }, |
| 44 | { key: "iterations", label: "Iterations per tick", min: 2, max: 30, step: 1, defaultValue: 12 }, |
| 45 | { |
| 46 | key: "preset", |
| 47 | label: "Regime preset", |
| 48 | options: [ |
| 49 | { value: "custom", label: "Custom" }, |
| 50 | { value: "coral", label: "Coral (f 0.0545, k 0.062)" }, |
| 51 | { value: "mitosis", label: "Mitosis (f 0.0367, k 0.0649)" }, |
| 52 | { value: "worms", label: "Worms (f 0.078, k 0.061)" }, |
| 53 | { value: "spots", label: "Spots (f 0.03, k 0.062)" }, |
| 54 | ], |
| 55 | defaultValue: "coral", |
| 56 | }, |
| 57 | ]; |
| 58 | } |
| 59 | |
| 60 | protected onReset(): void { |
| 61 | this.n = Number(this.str("resolution")); |
| 62 | const cell = Math.min(this.width, this.height) / this.n; |
| 63 | this.drawSize = cell * this.n; |
| 64 | this.offsetX = Math.floor((this.width - this.drawSize) / 2); |
| 65 | this.offsetY = Math.floor((this.height - this.drawSize) / 2); |
| 66 | const size = this.n * this.n; |
| 67 | this.u = new Float32Array(size).fill(1); |
| 68 | this.v = new Float32Array(size); |
| 69 | this.u2 = new Float32Array(size); |
| 70 | this.v2 = new Float32Array(size); |
| 71 | const blobs = 6; |
| 72 | for (let b = 0; b < blobs; b++) { |
| 73 | this.stamp(this.rng() * this.n, this.rng() * this.n, 3 + this.rng() * 3); |
| 74 | } |
| 75 | this.iterations = 0; |
| 76 | this.activity = 0; |
| 77 | this.image = null; |
| 78 | } |
| 79 | |
| 80 | private stamp(cx: number, cy: number, radius: number): void { |
| 81 | for (let y = Math.max(0, Math.floor(cy - radius)); y < Math.min(this.n, Math.ceil(cy + radius)); y++) { |
| 82 | for (let x = Math.max(0, Math.floor(cx - radius)); x < Math.min(this.n, Math.ceil(cx + radius)); x++) { |
| 83 | if (Math.hypot(x - cx, y - cy) <= radius) { |
| 84 | this.v[y * this.n + x] = 1; |
| 85 | } |
| 86 | } |
| 87 | } |
| 88 | } |
| 89 | |
| 90 | protected onParameterChange(key: string): void { |
| 91 | if (key === "preset") { |
| 92 | const preset = this.str("preset"); |
| 93 | if (preset !== "custom") { |
| 94 | const rates: Record<string, [number, number]> = { |
| 95 | coral: [0.0545, 0.062], |
| 96 | mitosis: [0.0367, 0.0649], |
| 97 | worms: [0.078, 0.061], |
| 98 | spots: [0.03, 0.062], |
| 99 | }; |
| 100 | const [f, k] = rates[preset]; |
| 101 | this.setInternalValue("feed", f); |
| 102 | this.setInternalValue("kill", k); |
| 103 | } |
| 104 | this.reset(); |
| 105 | } else if (key === "feed" || key === "kill") { |
| 106 | this.setInternalValue("preset", "custom"); |
| 107 | } else if (key === "resolution") { |
| 108 | this.reset(); |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | resize(width: number, height: number): void { |
| 113 | this.width = width; |
| 114 | this.height = height; |
| 115 | this.reset(); |
| 116 | } |
| 117 | |
| 118 | onPointer(state: PointerState): void { |
| 119 | this.pointer = state; |
| 120 | if (state.inside && state.down) { |
| 121 | const gx = ((state.x - this.offsetX) / this.drawSize) * this.n; |
| 122 | const gy = ((state.y - this.offsetY) / this.drawSize) * this.n; |
| 123 | this.stamp(gx, gy, 3); |
| 124 | } |
| 125 | } |
| 126 | |
| 127 | protected onUpdate(): void { |
| 128 | const n = this.n; |
| 129 | const u = this.u; |
| 130 | const v = this.v; |
| 131 | const u2 = this.u2; |
| 132 | const v2 = this.v2; |
| 133 | const feed = this.num("feed"); |
| 134 | const kill = this.num("kill"); |
| 135 | let activity = 0; |
| 136 | const iterations = this.num("iterations"); |
| 137 | for (let it = 0; it < iterations; it++) { |
| 138 | for (let y = 0; y < n; y++) { |
| 139 | const up = ((y - 1 + n) % n) * n; |
| 140 | const down = ((y + 1) % n) * n; |
| 141 | const row = y * n; |
| 142 | for (let x = 0; x < n; x++) { |
| 143 | const left = (x - 1 + n) % n; |
| 144 | const right = (x + 1) % n; |
| 145 | const i = row + x; |
| 146 | const uvv = u[i] * v[i] * v[i]; |
| 147 | const lapU = |
| 148 | -u[i] + |
| 149 | 0.2 * (u[up + x] + u[down + x] + u[row + left] + u[row + right]) + |
| 150 | 0.05 * |
| 151 | (u[up + left] + u[up + right] + u[down + left] + u[down + right]); |
| 152 | const lapV = |
| 153 | -v[i] + |
| 154 | 0.2 * (v[up + x] + v[down + x] + v[row + left] + v[row + right]) + |
| 155 | 0.05 * |
| 156 | (v[up + left] + v[up + right] + v[down + left] + v[down + right]); |
| 157 | const nu = u[i] + this.Du * lapU - uvv + feed * (1 - u[i]); |
| 158 | const nv = v[i] + this.Dv * lapV + uvv - (kill + feed) * v[i]; |
| 159 | u2[i] = nu > 1 ? 1 : nu < 0 ? 0 : nu; |
| 160 | v2[i] = nv > 1 ? 1 : nv < 0 ? 0 : nv; |
| 161 | activity += Math.abs(nv - v[i]); |
| 162 | } |
| 163 | } |
| 164 | u.set(u2); |
| 165 | v.set(v2); |
| 166 | } |
| 167 | this.iterations += iterations; |
| 168 | this.activity = activity / (n * n * iterations); |
| 169 | } |
| 170 | |
| 171 | render(ctx: CanvasRenderingContext2D, theme: ThemeColors): void { |
| 172 | ctx.fillStyle = theme.bg; |
| 173 | ctx.fillRect(0, 0, this.width, this.height); |
| 174 | if (!this.image) this.image = new ImageData(this.n, this.n); |
| 175 | const data = this.image.data; |
| 176 | for (let i = 0; i < this.u.length; i++) { |
| 177 | sampleInto(MAGMA, 1 - this.u[i], data, i * 4); |
| 178 | } |
| 179 | if (!this.scratch) { |
| 180 | this.scratch = document.createElement("canvas"); |
| 181 | this.scratch.width = this.n; |
| 182 | this.scratch.height = this.n; |
| 183 | } |
| 184 | const offCtx = this.scratch.getContext("2d")!; |
| 185 | offCtx.putImageData(this.image, 0, 0); |
| 186 | ctx.imageSmoothingEnabled = true; |
| 187 | ctx.drawImage(this.scratch, this.offsetX, this.offsetY, this.drawSize, this.drawSize); |
| 188 | } |
| 189 | |
| 190 | getMetrics() { |
| 191 | let vSum = 0; |
| 192 | for (let i = 0; i < this.v.length; i++) vSum += this.v[i]; |
| 193 | return { |
| 194 | iterations: this.iterations, |
| 195 | feed: this.num("feed").toFixed(3), |
| 196 | kill: this.num("kill").toFixed(3), |
| 197 | meanV: `${((vSum / this.v.length) * 100).toFixed(1)} %`, |
| 198 | activity: this.activity.toExponential(2), |
| 199 | }; |
| 200 | } |
| 201 | |
| 202 | describe(): string { |
| 203 | let vSum = 0; |
| 204 | for (let i = 0; i < this.v.length; i++) vSum += this.v[i]; |
| 205 | const percent = ((vSum / this.v.length) * 100).toFixed(1); |
| 206 | return `Gray-Scott at feed ${this.num("feed").toFixed(3)}, kill ${this.num("kill").toFixed(3)}: reagent B covers ${percent}% of the field.`; |
| 207 | } |
| 208 | |
| 209 | entities(): number { |
| 210 | return this.n * this.n; |
| 211 | } |
| 212 | } |
| 213 | |
Performance
- Time per tick
- O(cells × iterations) per tick with a 9-point stencil
- Space
- Four Float32Arrays (double-buffered u and v)
- Notes
- Activity (mean |Δv| per iteration) tells you whether the pattern is still evolving or has settled.
Live numbers (FPS, frame time, steps per second, microseconds per step, entity count) are in the performance overlay on the simulation above: the pulse icon in its top-right corner.