physics

Reaction Diffusion

Interactive Gray-Scott Turing patterns simulation. Explore how chemical reactions and diffusion create complex, beautiful structures.

Simulation Parameters

Presets

Color Theme

0
Generation
60
FPS
0.0
Avg V

How it works: The Gray-Scott model simulates two chemicals (u and v) diffusing and reacting. Varying the feed rate (F) and kill rate (k) produces intricate Turing patterns. Use Paint Mode to interactively modify the simulation.

Developer Reference

Core Algorithm & Standalone Script

Standalone, zero-dependency JavaScript implementation powering this tool. Free to inspect, copy, and build upon.

// Preset patterns
    const PRESETS = {
      spots: { F: 0.035, k: 0.065 },
      stripes: { F: 0.060, k: 0.062 },
      maze: { F: 0.029, k: 0.057 },
      holes: { F: 0.039, k: 0.058 },
      coral: { F: 0.055, k: 0.062 },
      worms: { F: 0.078, k: 0.061 },
    };

    // Color themes
    const COLOR_THEMES = {
      fire: [
        [10, 10, 10],
        [26, 26, 78],
        [255, 34, 0],
        [255, 255, 0],
      ],
      ocean: [
        [10, 10, 10],
        [0, 26, 77],
        [0, 204, 255],
        [255, 255, 255],
      ],
      forest: [
        [10, 10, 10],
        [26, 58, 26],
        [0, 200, 150],
        [255, 255, 0],
      ],
      plasma: [
        [10, 10, 10],
        [74, 0, 128],
        [255, 0, 255],
        [255, 255, 0],
      ],
      twilight: [
        [10, 10, 10],
        [42, 26, 74],
        [255, 34, 0],
        [255, 136, 0],
      ],
      void: [
        [10, 10, 10],
        [10, 10, 10],
        [255, 255, 255],
        [255, 255, 255],
      ],
    };

    // Canvas setup
    const canvas = document.getElementById("simulation-canvas");
    const ctx = canvas.getContext("2d", { willReadFrequently: true });
    const width = canvas.width;
    const height = canvas.height;

    // Simulation state
    let u = new Float32Array(width * height);
    let v = new Float32Array(width * height);
    let uNext = new Float32Array(width * height);
    let vNext = new Float32Array(width * height);

    // Parameters
    let F = 0.035;
    let k = 0.065;
    let Du = 0.2097;
    let Dv = 0.105;
    let iterationsPerFrame = 8;
    let isPlaying = true;
    let isPaintMode = false;
    let currentTheme = "fire";
    let generation = 0;
    let lastFrameTime = Date.now();
    let frameCount = 0;

    // Initialize simulation
    function initializeSimulation() {
      // Fill u with 1.0, v with 0.0
      u.fill(1.0);
      v.fill(0.0);

      // Add random noise to v in center
      const centerX = Math.floor(width / 2);
      const centerY = Math.floor(height / 2);
      const radius = 20;

      for (let y = centerY - radius; y < centerY + radius; y++) {
        for (let x = centerX - radius; x < centerX + radius; x++) {
          if (x >= 0 && x < width && y >= 0 && y < height) {
            const dist = Math.hypot(x - centerX, y - centerY);
            if (dist < radius) {
              v[y * width + x] = Math.random();
            }
          }
        }
      }

      generation = 0;
      updateGeneration();
    }

    // Laplacian using 5-point stencil
    function laplacian(field, x, y) {
      const n = width;
      const center = field[y * n + x];
      const north = field[((y - 1 + height) % height) * n + x];
      const south = field[((y + 1) % height) * n + x];
      const west = field[y * n + ((x - 1 + width) % width)];
      const east = field[y * n + ((x + 1) % width)];

      return north + south + west + east - 4.0 * center;
    }

    // Gray-Scott update step
    function update() {
      // Compute new values
      for (let y = 0; y < height; y++) {
        for (let x = 0; x < width; x++) {
          const idx = y * width + x;
          const uVal = u[idx];
          const vVal = v[idx];

          const laplacianU = laplacian(u, x, y);
          const laplacianV = laplacian(v, x, y);

          // ∂u/∂t = Dᵤ∇²u − uv² + F(1−u)
          uNext[idx] =
            uVal +
            Du * laplacianU -
            uVal * vVal * vVal +
            F * (1.0 - uVal);

          // ∂v/∂t = Dᵥ∇²v + uv² − (F+k)v
          vNext[idx] =
            vVal +
            Dv * laplacianV +
            uVal * vVal * vVal -
            (F + k) * vVal;

          // Clamp to [0, 1]
          uNext[idx] = Math.max(0, Math.min(1, uNext[idx]));
          vNext[idx] = Math.max(0, Math.min(1, vNext[idx]));
        }
      }

      // Swap buffers
      [u, uNext] = [uNext, u];
      [v, vNext] = [vNext, v];
    }

    // Render simulation to canvas
    function render() {
      const imageData = ctx.createImageData(width, height);
      const data = imageData.data;
      const theme = COLOR_THEMES[currentTheme];

      for (let i = 0; i < width * height; i++) {
        const vVal = v[i];
        const t = Math.max(0, Math.min(1, vVal)); // Normalize to [0, 1]

        // Interpolate through color theme
        let r, g, b;
        if (t < 0.33) {
          // Interpolate between color 0 and 1
          const localT = t / 0.33;
          const c0 = theme[0];
          const c1 = theme[1];
          r = Math.round(c0[0] * (1 - localT) + c1[0] * localT);
          g = Math.round(c0[1] * (1 - localT) + c1[1] * localT);
          b = Math.round(c0[2] * (1 - localT) + c1[2] * localT);
        } else if (t < 0.67) {
          // Interpolate between color 1 and 2
          const localT = (t - 0.33) / 0.34;
          const c1 = theme[1];
          const c2 = theme[2];
          r = Math.round(c1[0] * (1 - localT) + c2[0] * localT);
          g = Math.round(c1[1] * (1 - localT) + c2[1] * localT);
          b = Math.round(c1[2] * (1 - localT) + c2[2] * localT);
        } else {
          // Interpolate between color 2 and 3
          const localT = (t - 0.67) / 0.33;
          const c2 = theme[2];
          const c3 = theme[3];
          r = Math.round(c2[0] * (1 - localT) + c3[0] * localT);
          g = Math.round(c2[1] * (1 - localT) + c3[1] * localT);
          b = Math.round(c2[2] * (1 - localT) + c3[2] * localT);
        }

        const idx = i * 4;
        data[idx] = r;
        data[idx + 1] = g;
        data[idx + 2] = b;
        data[idx + 3] = 255;
      }

      ctx.putImageData(imageData, 0, 0);
    }

    // Paint on canvas (inject v)
    function handleCanvasMouse(e) {
      if (!isPaintMode) return;

      const rect = canvas.getBoundingClientRect();
      const x = Math.floor(((e.clientX - rect.left) / rect.width) * width);
      const y = Math.floor(((e.clientY - rect.top) / rect.height) * height);

      if (x >= 0 && x < width && y >= 0 && y < height) {
        const brushSize = 5;
        for (let dy = -brushSize; dy <= brushSize; dy++) {
          for (let dx = -brushSize; dx <= brushSize; dx++) {
            const nx = (x + dx + width) % width;
            const ny = (y + dy + height) % height;
            if (Math.hypot(dx, dy) <= brushSize) {
              v[ny * width + nx] = 1.0;
            }
          }
        }
      }
    }

    // FPS and stats tracking
    function updateStats() {
      frameCount++;
      const now = Date.now();
      const deltaTime = now - lastFrameTime;

      if (deltaTime >= 1000) {
        const fps = Math.round((frameCount * 1000) / deltaTime);
        document.getElementById("fps-display").textContent = fps;
        frameCount = 0;
        lastFrameTime = now;
      }

      // Calculate average v
      let sumV = 0;
      for (let i = 0; i < v.length; i++) {
        sumV += v[i];
      }
      const avgV = (sumV / v.length).toFixed(3);
      document.getElementById("avg-v").textContent = avgV;
    }

    function updateGeneration() {
      document.getElementById("generation-count").textContent = generation;
    }

    // Animation loop
    function animate() {
      if (isPlaying) {
        for (let i = 0; i < iterationsPerFrame; i++) {
          update();
          generation++;
        }
        updateGeneration();
      }

      render();
      updateStats();
      requestAnimationFrame(animate);
    }

    // Event listeners
    document.getElementById("f-slider").addEventListener("input", (e) => {
      F = parseFloat(e.target.value);
      document.getElementById("f-value").textContent = F.toFixed(3);
    });

    document.getElementById("k-slider").addEventListener("input", (e) => {
      k = parseFloat(e.target.value);
      document.getElementById("k-value").textContent = k.toFixed(3);
    });

    document.getElementById("du-slider").addEventListener("input", (e) => {
      Du = parseFloat(e.target.value);
      document.getElementById("du-value").textContent = Du.toFixed(4);
    });

    document.getElementById("dv-slider").addEventListener("input", (e) => {
      Dv = parseFloat(e.target.value);
      document.getElementById("dv-value").textContent = Dv.toFixed(4);
    });

    document.getElementById("speed-slider").addEventListener("input", (e) => {
      iterationsPerFrame = parseInt(e.target.value);
      document.getElementById("speed-value").textContent = iterationsPerFrame;
    });

    // Preset buttons
    document.querySelectorAll(".btn-preset").forEach((btn) => {
      btn.addEventListener("click", () => {
        const preset = btn.dataset.preset;
        const params = PRESETS[preset];
        F = params.F;
        k = params.k;

        document.getElementById("f-slider").value = F;
        document.getElementById("k-slider").value = k;
        document.getElementById("f-value").textContent = F.toFixed(3);
        document.getElementById("k-value").textContent = k.toFixed(3);

        document.querySelectorAll(".btn-preset").forEach((b) => {
          b.classList.remove("active");
        });
        btn.classList.add("active");
      });
    });

    // Color theme swatches
    document.querySelectorAll(".swatch").forEach((swatch) => {
      swatch.addEventListener("click", () => {
        currentTheme = swatch.dataset.theme;
        document.querySelectorAll(".swatch").forEach((s) => {
          s.classList.remove("active");
        });
        swatch.classList.add("active");
      });
    });

    // Paint mode toggle
    document.getElementById("paint-mode").addEventListener("change", (e) => {
      isPaintMode = e.target.checked;
    });

    // Canvas mouse events
    canvas.addEventListener("mousemove", handleCanvasMouse);
    canvas.addEventListener("mousedown", handleCanvasMouse);

    // Control buttons
    document.getElementById("reset-btn").addEventListener("click", () => {
      initializeSimulation();
    });

    document.getElementById("randomize-btn").addEventListener("click", () => {
      u.fill(1.0);
      v.fill(0.0);

      for (let i = 0; i < width * height; i++) {
        if (Math.random() < 0.1) {
          v[i] = Math.random();
        }
      }

      generation = 0;
      updateGeneration();
    });

    document.getElementById("toggle-play-btn").addEventListener("click", (e) => {
      isPlaying = !isPlaying;
      e.target.textContent = isPlaying ? "Pause" : "Play";
    });

    // Initialize and start
    initializeSimulation();
    animate();