Skip to content

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.

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

1import { BaseExperiment } from "../core/base-experiment";
2import { MAGMA, sampleInto } from "../render/colormaps";
3import 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 */
10export 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.