Chris Collins

Lab · Playground

Reaction–diffusion

The Gray–Scott model — two imaginary chemicals that spread and react — grows coral, fingerprints and dividing cells from a few drops.

Code blocks on this page are editable — change them and press Run.

Alan Turing suggested in 1952 that spots and stripes on animals might come from two chemicals that diffuse at different speeds and react with each other. The Gray–Scott model is the simplest version that makes a zoo of patterns. Every cell of the grid holds two concentrations, A and B:

  • A is fed in everywhere at the feed rate f;

  • B is removed everywhere at the kill rate k (plus f);

  • where they meet, A + 2B → 3B — B eats A and makes more of itself;

  • both spread to their neighbours (a Laplacian), A twice as fast as B.

That is the whole simulation — about ten lines in step() below. Which pattern you get depends almost entirely on f and k; the buttons jump to well-known pairs. Click or drag on the picture to drop more B.

This runs in plain JavaScript on a 160 × 160 grid, twelve steps per frame. (A GLSL version would be much faster, but needs to render into a texture and read it back each step, which a single fragment shader on this site cannot do.)

Gray–Scott reaction–diffusion
<!doctype html>
<style>
  html, body { margin: 0; height: 100%; background: #10131a; color: #dde; font: 13px system-ui, sans-serif; }
  body { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 6px; box-sizing: border-box; }
  canvas { height: 250px; aspect-ratio: 1; max-width: 100%; image-rendering: pixelated; cursor: crosshair; touch-action: none; }
  .bar { display: flex; gap: 4px; flex-wrap: wrap; justify-content: center; }
  button { font: inherit; padding: 3px 9px; border: 1px solid #445; border-radius: 4px; background: #1d2230; color: #dde; }
  button.on { background: #3a6ea5; border-color: #3a6ea5; }
</style>
<div class="bar" id="bar"></div>
<canvas id="c" width="160" height="160"></canvas>
<script>
// Things to change: the presets (feed f, kill k), the diffusion rates, steps per frame.
const PRESETS = {
  coral:    { f: 0.0545, k: 0.062 },
  mitosis:  { f: 0.0367, k: 0.0649 },
  spots:    { f: 0.030,  k: 0.062 },
  maze:     { f: 0.029,  k: 0.057 },
  waves:    { f: 0.014,  k: 0.045 },
};
const DA = 1.0, DB = 0.5;     // diffusion rates: A spreads twice as fast as B
const STEPS = 12;             // simulation steps per animation frame
const N = 160;

let { f, k } = PRESETS.coral;
let A = new Float32Array(N * N), B = new Float32Array(N * N);
let A2 = new Float32Array(N * N), B2 = new Float32Array(N * N);
const cv = document.getElementById('c'), ctx = cv.getContext('2d');
const img = ctx.createImageData(N, N);

function seed() {
  A.fill(1); B.fill(0);
  for (let i = 0; i < 12; i++) drop(20 + Math.random() * (N - 40), 20 + Math.random() * (N - 40), 4);
}
function drop(cx, cy, r) {
  for (let y = -r; y <= r; y++) for (let x = -r; x <= r; x++) {
    if (x * x + y * y > r * r) continue;
    const i = ((cy + y + N) % N | 0) * N + ((cx + x + N) % N | 0);
    B[i] = 1;
  }
}

function step() {
  for (let y = 0; y < N; y++) {
    const up = ((y + N - 1) % N) * N, row = y * N, dn = ((y + 1) % N) * N;
    for (let x = 0; x < N; x++) {
      const l = (x + N - 1) % N, r = (x + 1) % N, i = row + x;
      // 3×3 Laplacian: centre −1, edges 0.2, corners 0.05; the grid wraps at the edges.
      const lapA = -A[i] + 0.2 * (A[up + x] + A[dn + x] + A[row + l] + A[row + r])
                 + 0.05 * (A[up + l] + A[up + r] + A[dn + l] + A[dn + r]);
      const lapB = -B[i] + 0.2 * (B[up + x] + B[dn + x] + B[row + l] + B[row + r])
                 + 0.05 * (B[up + l] + B[up + r] + B[dn + l] + B[dn + r]);
      const abb = A[i] * B[i] * B[i];
      A2[i] = A[i] + DA * lapA - abb + f * (1 - A[i]);
      B2[i] = B[i] + DB * lapB + abb - (k + f) * B[i];
    }
  }
  [A, A2] = [A2, A]; [B, B2] = [B2, B];
}

function render() {
  const d = img.data;
  for (let i = 0; i < N * N; i++) {
    const v = Math.min(1, Math.max(0, (A[i] - B[i]) ));   // 1 = all A, 0 = lots of B
    const t = 1 - v;
    d[i * 4]     = 16 + 230 * t * t;
    d[i * 4 + 1] = 20 + 160 * t;
    d[i * 4 + 2] = 40 + 120 * Math.sqrt(t);
    d[i * 4 + 3] = 255;
  }
  ctx.putImageData(img, 0, 0);
}

function frame() {
  for (let s = 0; s < STEPS; s++) step();
  render();
  requestAnimationFrame(frame);
}

const bar = document.getElementById('bar');
for (const [name, p] of Object.entries(PRESETS)) {
  const b = document.createElement('button');
  b.textContent = name;
  b.onclick = () => { ({ f, k } = p); seed(); bar.querySelectorAll('button').forEach(x => x.classList.toggle('on', x === b)); };
  if (name === 'coral') b.className = 'on';
  bar.append(b);
}
const paint = e => {
  if (!e.buttons && e.type !== 'pointerdown') return;
  const r = cv.getBoundingClientRect();
  drop((e.clientX - r.left) / r.width * N, (e.clientY - r.top) / r.height * N, 3);
};
cv.addEventListener('pointerdown', paint);
cv.addEventListener('pointermove', paint);

seed();
frame();
</script>

Things to change

  • PRESETS — f between 0.01 and 0.08 and k between 0.04 and 0.07 is where the interesting patterns live; outside it, everything dies (all A) or floods (all B). Small changes in k matter most: try coral with k: 0.0605, then 0.0635.

  • DB — B’s diffusion rate. At 0.5 the patterns are crisp; nudging it toward DA washes them out, which is Turing’s point: the two speeds must differ.

  • STEPS — more steps per frame grow the pattern faster, at the cost of frame rate.

  • render() maps the concentrations to colour; try d[i*4] = 255 * B[i] * 3 for a plain heat map of B alone.

  • N = 256 gives finer detail and runs about 2.5× slower.