9

Reaction-Diffusion Painter

tap a brush to flood the field · drag to paint regions

Gray-Scott reaction-diffusion on a coarse grid with **spatially varying parameters you paint yourself**. Two chemicals and diffuse and react according to and , with , , simulated by an explicit Euler step on a 5-point Laplacian. Globally fixing and collapses the system to a single mode, but Pearson's famous parameter portrait reveals a thin manifold in where wildly different morphologies sit side by side. Here every grid cell carries its **own** , picked by a palette of brushes — *spots* , *stripes* , *labyrinth* , *coral* , *waves* — so painted regions self-organise into their characteristic regimes while the boundaries between them negotiate live. The display maps through a viridis-on-deep-purple LUT.

idle
299 lines · vanilla
view source
// Reaction-Diffusion Painter — Gray-Scott with painted (f, k) fields.
//
// User paints regions of the grid with different (f, k) brushes. Each region
// develops its own pattern from the same diffusion dynamics.

let W, H, GW, GH, SCALE;
let A1, A2, B1, B2;     // chemical concentrations
let Ffield, Kfield;     // per-cell parameters
let regionId;           // brush id painted into each cell (-1 = default)
let img, pix;
let selectedBrush = 2;  // default to "labyrinth"
let mousePrev = null;
let painted = false;    // becomes true after first user paint stroke
let frameNo = 0;
let lastClickConsumed = -1;

// Famous Gray-Scott parameter portrait presets.
// (Pearson 1993; Munafo's well-known map.)
const BRUSHES = [
  { name: "spots",     f: 0.0367, k: 0.0649, color: [255, 90, 110] },
  { name: "stripes",   f: 0.022,  k: 0.051,  color: [255, 200, 70] },
  { name: "labyrinth", f: 0.029,  k: 0.057,  color: [110, 240, 160] },
  { name: "coral",     f: 0.062,  k: 0.062,  color: [120, 180, 255] },
  { name: "waves",     f: 0.014,  k: 0.045,  color: [220, 130, 255] },
];

const DEFAULT_F = 0.054;
const DEFAULT_K = 0.062;

// Palette: deep purple -> blue -> teal -> green -> yellow (approx viridis).
const PALETTE = (() => {
  const stops = [
    [0.00,  18,  10,  42],
    [0.20,  68,  20, 110],
    [0.40,  60,  70, 160],
    [0.55,  35, 130, 165],
    [0.70,  50, 175, 130],
    [0.85, 180, 210,  80],
    [1.00, 253, 231,  37],
  ];
  const lut = new Uint8Array(256 * 3);
  for (let i = 0; i < 256; i++) {
    const t = i / 255;
    let s = 0;
    while (s < stops.length - 2 && stops[s + 1][0] < t) s++;
    const a = stops[s], b = stops[s + 1];
    const u = (t - a[0]) / (b[0] - a[0]);
    lut[i * 3]     = (a[1] + (b[1] - a[1]) * u) | 0;
    lut[i * 3 + 1] = (a[2] + (b[2] - a[2]) * u) | 0;
    lut[i * 3 + 2] = (a[3] + (b[3] - a[3]) * u) | 0;
  }
  return lut;
})();

function init({ canvas, ctx, width, height, input }) {
  W = width; H = height;
  // Target grid ~180x120, scale by what fits.
  SCALE = Math.max(2, Math.floor(Math.min(W / 180, H / 120)));
  if (SCALE < 2) SCALE = 2;
  GW = Math.max(60, Math.floor(W / SCALE));
  GH = Math.max(40, Math.floor(H / SCALE));
  const N = GW * GH;
  A1 = new Float32Array(N);
  A2 = new Float32Array(N);
  B1 = new Float32Array(N);
  B2 = new Float32Array(N);
  Ffield = new Float32Array(N);
  Kfield = new Float32Array(N);
  regionId = new Int8Array(N);
  for (let i = 0; i < N; i++) {
    A1[i] = 1; A2[i] = 1;
    Ffield[i] = DEFAULT_F;
    Kfield[i] = DEFAULT_K;
    regionId[i] = -1;
  }
  // Seed a small disturbance in the center so something visible appears even
  // before the user paints — but keep it small.
  const cx = GW >> 1, cy = GH >> 1, r = 4;
  for (let y = cy - r; y <= cy + r; y++) {
    for (let x = cx - r; x <= cx + r; x++) {
      const dx = x - cx, dy = y - cy;
      if (dx * dx + dy * dy <= r * r) {
        const i = ((y + GH) % GH) * GW + ((x + GW) % GW);
        B1[i] = 1; A1[i] = 0.5;
      }
    }
  }
  img = ctx.createImageData(GW, GH);
  pix = img.data;
  for (let i = 3; i < pix.length; i += 4) pix[i] = 255;
}

// --- UI geometry: palette of brushes along the top --------------------------

function paletteLayout() {
  // Compact palette top-left. Tiles scale with canvas width.
  const n = BRUSHES.length;
  const margin = 8;
  const maxTotal = Math.min(W - margin * 2, 460);
  const gap = 6;
  const tileW = Math.floor((maxTotal - gap * (n - 1)) / n);
  const tileH = Math.max(28, Math.floor(tileW * 0.42));
  return { margin, gap, tileW, tileH };
}

function pointInPalette(px, py) {
  const { margin, gap, tileW, tileH } = paletteLayout();
  if (py < margin || py > margin + tileH) return -1;
  if (px < margin) return -1;
  for (let i = 0; i < BRUSHES.length; i++) {
    const x0 = margin + i * (tileW + gap);
    if (px >= x0 && px <= x0 + tileW) return i;
  }
  return -1;
}

// --- painting ---------------------------------------------------------------

function paintAt(px, py, brushIdx) {
  const b = BRUSHES[brushIdx];
  // Brush radius in grid cells. ~7-8% of grid width.
  const r = Math.max(4, Math.floor(GW * 0.07));
  const gx = Math.floor(px / SCALE);
  const gy = Math.floor(py / SCALE);
  const r2 = r * r;
  for (let dy = -r; dy <= r; dy++) {
    for (let dx = -r; dx <= r; dx++) {
      const d2 = dx * dx + dy * dy;
      if (d2 > r2) continue;
      const xi = ((gx + dx) % GW + GW) % GW;
      const yi = ((gy + dy) % GH + GH) % GH;
      const i = yi * GW + xi;
      Ffield[i] = b.f;
      Kfield[i] = b.k;
      regionId[i] = brushIdx;
      // Inject some B near the center of the stroke so the pattern boots.
      if (d2 < (r * 0.55) * (r * 0.55)) {
        B1[i] = Math.max(B1[i], 0.9);
        A1[i] = Math.min(A1[i], 0.3);
      }
    }
  }
}

function paintLine(x0, y0, x1, y1, brushIdx) {
  const dx = x1 - x0, dy = y1 - y0;
  const dist = Math.hypot(dx, dy);
  const steps = Math.max(1, Math.ceil(dist / (SCALE * 2)));
  for (let s = 0; s <= steps; s++) {
    const t = s / steps;
    paintAt(x0 + dx * t, y0 + dy * t, brushIdx);
  }
}

// Flood the entire field with one brush's (f, k) and re-seed B
// disturbances on a coarse grid. Called whenever the user picks a brush
// from the palette, so a single tap produces an immediate, obvious
// pattern instead of just changing which paint will come out of a drag.
function floodFill(brushIdx) {
  const b = BRUSHES[brushIdx];
  const N = GW * GH;
  for (let i = 0; i < N; i++) {
    Ffield[i] = b.f;
    Kfield[i] = b.k;
    regionId[i] = brushIdx;
    A1[i] = 1;
    A2[i] = 1;
    B1[i] = 0;
    B2[i] = 0;
  }
  // Coarse seed pattern — a 6x4 grid of small B blobs gives every region
  // of the screen a nucleation point so patterns fill in quickly.
  const sx = 6, sy = 4;
  const r = 3;
  for (let gy = 0; gy < sy; gy++) {
    for (let gx = 0; gx < sx; gx++) {
      const cx = ((gx + 0.5) / sx) * GW;
      const cy = ((gy + 0.5) / sy) * GH;
      for (let dy = -r; dy <= r; dy++) {
        for (let dx = -r; dx <= r; dx++) {
          if (dx * dx + dy * dy > r * r) continue;
          const xi = ((Math.floor(cx) + dx) % GW + GW) % GW;
          const yi = ((Math.floor(cy) + dy) % GH + GH) % GH;
          const i = yi * GW + xi;
          B1[i] = 1;
          A1[i] = 0.3;
        }
      }
    }
  }
}

// --- Gray-Scott step --------------------------------------------------------

function step(A, B, Ao, Bo) {
  const dA = 1.0, dB = 0.5, dt = 1.0;
  for (let y = 0; y < GH; y++) {
    const ym = (y - 1 + GH) % GH;
    const yp = (y + 1) % GH;
    const yrow = y * GW;
    const ymrow = ym * GW;
    const yprow = yp * GW;
    for (let x = 0; x < GW; x++) {
      const xm = (x - 1 + GW) % GW;
      const xp = (x + 1) % GW;
      const i = yrow + x;
      const a = A[i], b = B[i];
      const lapA = (A[ymrow + x] + A[yprow + x] + A[yrow + xm] + A[yrow + xp]) * 0.25 - a;
      const lapB = (B[ymrow + x] + B[yprow + x] + B[yrow + xm] + B[yrow + xp]) * 0.25 - b;
      const abb = a * b * b;
      const f = Ffield[i];
      const k = Kfield[i];
      let na = a + (dA * lapA - abb + f * (1 - a)) * dt;
      let nb = b + (dB * lapB + abb - (k + f) * b) * dt;
      if (na < 0) na = 0; else if (na > 1) na = 1;
      if (nb < 0) nb = 0; else if (nb > 1) nb = 1;
      Ao[i] = na;
      Bo[i] = nb;
    }
  }
}

// --- rendering --------------------------------------------------------------

function renderField() {
  const N = GW * GH;
  for (let i = 0; i < N; i++) {
    // Normalize B (typical max ~0.4) into [0,1].
    let v = B1[i] * 2.6;
    if (v > 1) v = 1;
    else if (v < 0) v = 0;
    const ci = (v * 255) | 0;
    const j = i << 2;
    const p = ci * 3;
    pix[j]     = PALETTE[p];
    pix[j + 1] = PALETTE[p + 1];
    pix[j + 2] = PALETTE[p + 2];
  }
}

function drawPalette(ctx) {
  const { margin, gap, tileW, tileH } = paletteLayout();
  ctx.save();
  ctx.font = `${Math.max(11, Math.floor(tileH * 0.42))}px system-ui, sans-serif`;
  ctx.textBaseline = "middle";
  ctx.textAlign = "center";
  for (let i = 0; i < BRUSHES.length; i++) {
    const b = BRUSHES[i];
    const x0 = margin + i * (tileW + gap);
    const y0 = margin;
    const isSel = i === selectedBrush;
    // Selected tiles fill with the brush color so the pick is obvious
    // at a glance; unselected stay dark with a thin color swatch.
    if (isSel) {
      ctx.fillStyle = `rgba(${b.color[0]}, ${b.color[1]}, ${b.color[2]}, 0.9)`;
      ctx.fillRect(x0, y0, tileW, tileH);
      ctx.lineWidth = 2;
      ctx.strokeStyle = "rgba(255, 255, 255, 0.85)";
      ctx.strokeRect(x0 + 1, y0 + 1, tileW - 2, tileH - 2);
    } else {
      ctx.fillStyle = "rgba(15, 8, 30, 0.78)";
      ctx.fillRect(x0, y0, tileW, tileH);
      ctx.fillStyle = `rgb(${b.color[0]}, ${b.color[1]}, ${b.color[2]})`;
      ctx.fillRect(x0, y0, 4, tileH);
      ctx.lineWidth = 1;
      ctx.strokeStyle = "rgba(255, 255, 255, 0.18)";
      ctx.strokeRect(x0 + 0.5, y0 + 0.5, tileW - 1, tileH - 1);
    }
    ctx.fillStyle = isSel ? "#0a0418" : "rgba(230, 220, 245, 0.85)";
    ctx.fillText(b.name, x0 + tileW / 2 + 2, y0 + tileH / 2);
  }
  ctx.restore();
}

function drawHud(ctx) {
  const b = BRUSHES[selectedBrush];
  const { margin, tileH } = paletteLayout();
  const y = margin + tileH + 6;
  ctx.save();
  ctx.font = "11px system-ui, sans-serif";
  ctx.textBaseline = "top";
  ctx.fillStyle = "rgba(10, 4, 22, 0.65)";
  const label = `${b.name}  f=${b.f.toFixed(3)}  k=${b.k.toFixed(3)}`;
  const tw = ctx.measureText(label).width + 12;
  ctx.fillRect(margin, y, tw, 18);
  ctx.fillStyle = "#fff";
  ctx.fillText(label, margin + 6, y + 3);
  if (!painted) {
    const hint = "tap a brush to flood the field · drag to paint regions";
    const hw = ctx.measureText(hint).width + 12;
    ctx.fillStyle = "rgba(10, 4, 22, 0.65)";
    ctx.fillRect(margin, y + 22, hw, 18);
    ctx.fillStyle = "rgba(255, 255, 255, 0.92)";
    ctx.fillText(hint, margin + 6, y + 25);
  }
  ctx.restore();
}

// --- main loop --------------------------------------------------------------

function tick({ ctx, dt, frame, time, width, height, input }) {
  frameNo = frame;

  // Handle clicks: brush selection takes priority over painting.
  const clicks = input.consumeClicks();
  for (let c = 0; c < clicks.length; c++) {
    const cx = clicks[c].x, cy = clicks[c].y;
    const tileIdx = pointInPalette(cx, cy);
    if (tileIdx >= 0) {
      // Pick + flood. This is the load-bearing UX: a brush tap should
      // produce an immediate visible pattern, not just arm a future
      // drag.
      selectedBrush = tileIdx;
      floodFill(tileIdx);
      painted = true;
    } else {
      paintAt(cx, cy, selectedBrush);
      painted = true;
    }
  }

  // Drag-paint when held down outside the palette.
  if (input.mouseDown) {
    const mx = input.mouseX, my = input.mouseY;
    if (pointInPalette(mx, my) < 0) {
      if (mousePrev) {
        paintLine(mousePrev.x, mousePrev.y, mx, my, selectedBrush);
      } else {
        paintAt(mx, my, selectedBrush);
      }
      mousePrev = { x: mx, y: my };
      painted = true;
    } else {
      mousePrev = null;
    }
  } else {
    mousePrev = null;
  }

  // Sub-step the PDE several times per visual frame.
  const SUB = 8;
  for (let s = 0; s < SUB; s++) {
    step(A1, B1, A2, B2);
    let t = A1; A1 = A2; A2 = t;
    t = B1; B1 = B2; B2 = t;
  }

  renderField();
  ctx.putImageData(img, 0, 0);
  ctx.imageSmoothingEnabled = false;
  ctx.globalCompositeOperation = "copy";
  ctx.drawImage(ctx.canvas, 0, 0, GW, GH, 0, 0, W, H);
  ctx.globalCompositeOperation = "source-over";

  drawPalette(ctx);
  drawHud(ctx);
}

Comments (0)

Log in to comment.