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();