Reaction-Diffusion Patterns
by GameDev.net · WebGPU Compute (WGSL) · 25 Aug 2026
The Gray-Scott model: two chemicals diffuse and react, producing organic spots, stripes, and coral-like patterns. This version uses a two-pass update so the simulation reads a stable board.
What it demonstrates
A classic reaction-diffusion system on the GPU. Using a single storage buffer as a double buffer for two scalar fields. How tiny parameter changes in feed and kill produce very different patterns.
Common
fn cellIndex(gid: vec2<u32>) -> u32 {
return gid.y * u32(uniforms.resolution.x) + gid.x;
}
fn inBounds(gid: vec2<u32>) -> bool {
return gid.x < u32(uniforms.resolution.x) && gid.y < u32(uniforms.resolution.y);
}
fn wrap(coord: vec2<i32>, res: vec2<u32>) -> vec2<u32> {
let r = vec2<i32>(res);
let w = (coord + r) % r;
return vec2<u32>(w);
}
fn sampleAt(x: i32, y: i32, gid: vec2<u32>, channel: u32) -> f32 {
let res = vec2<u32>(uniforms.resolution);
let c = wrap(vec2<i32>(gid) + vec2<i32>(x, y), res);
let i = cellIndex(c);
switch (channel) {
case 0u: { return output[i].r; }
case 1u: { return output[i].g; }
case 2u: { return output[i].b; }
case 3u: { return output[i].a; }
default: { return 0.0; }
}
}
fn laplacian(gid: vec2<u32>, channel: u32) -> f32 {
var sum = 0.0;
sum = sum + sampleAt(-1, 0, gid, channel) * 0.2;
sum = sum + sampleAt(1, 0, gid, channel) * 0.2;
sum = sum + sampleAt(0, -1, gid, channel) * 0.2;
sum = sum + sampleAt(0, 1, gid, channel) * 0.2;
sum = sum + sampleAt(-1, -1, gid, channel) * 0.05;
sum = sum + sampleAt(1, -1, gid, channel) * 0.05;
sum = sum + sampleAt(-1, 1, gid, channel) * 0.05;
sum = sum + sampleAt(1, 1, gid, channel) * 0.05;
sum = sum - sampleAt(0, 0, gid, channel);
return sum;
}
fn hash2(p: vec2<u32>) -> f32 {
var h = p.x * 374761393u + p.y * 668265263u;
h = (h ^ (h >> 13u)) * 1274126177u;
return f32(h ^ (h >> 16u)) / 4294967295.0;
}
Compute (update)3
// Gray-Scott reaction-diffusion. The update pass reads the current A/B values
// from the red/green channels, computes the next state, and writes the next
// state into the blue/alpha channels. A separate copy pass commits it back.
const uFeed: f32 = 0.055;
const uKill: f32 = 0.062;
const uDiffA: f32 = 1.0;
const uDiffB: f32 = 0.5;
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
if (!inBounds(gid.xy)) {
return;
}
let idx = cellIndex(gid.xy);
if (uniforms.frame == 0u) {
let uv = vec2<f32>(gid.xy) / uniforms.resolution;
let a = 1.0;
let b = 0.0;
// A small block of B in the centre seeds the pattern.
let center = abs(uv - 0.5) < vec2<f32>(0.06);
let bSeed = select(b, 1.0, center.x && center.y);
// A little noise helps break symmetry.
let bNoisy = bSeed + hash2(gid.xy) * 0.02;
output[idx].r = a;
output[idx].g = bNoisy;
output[idx].b = a;
output[idx].a = bNoisy;
return;
}
let a = output[idx].r;
let b = output[idx].g;
let abb = a * b * b;
let lapA = laplacian(gid.xy, 0u);
let lapB = laplacian(gid.xy, 1u);
let da = uDiffA * lapA - abb + uFeed * (1.0 - a);
let db = uDiffB * lapB + abb - (uKill + uFeed) * b;
let dt = 1.0;
let nextA = clamp(a + da * dt, 0.0, 1.0);
let nextB = clamp(b + db * dt, 0.0, 1.0);
output[idx].b = nextA;
output[idx].a = nextB;
}
Compute (copy)1
// Commit the swapped buffer. Blue holds the next A, alpha holds the next B;
// copy them back to red and green for the next frame.
@compute @workgroup_size(8, 8, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
if (!inBounds(gid.xy)) {
return;
}
let idx = cellIndex(gid.xy);
output[idx].r = output[idx].b;
output[idx].g = output[idx].a;
}
Main Image
@fragment
fn main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let coord = vec2<u32>(pos.xy);
let idx = coord.y * u32(uniforms.resolution.x) + coord.x;
let a = output[idx].r;
let b = output[idx].g;
// B is the pattern; A is the feed background. A yellow-to-blue gradient
// makes the reaction front readable.
let pattern = vec3<f32>(0.95, 0.85, 0.2);
let background = vec3<f32>(0.05, 0.08, 0.15);
var col = mix(background, pattern, b);
col = mix(col, vec3<f32>(0.9, 0.95, 1.0), a * 0.15);
return vec4<f32>(col, 1.0);
}
Learn from this shader
How it works
The red channel stores chemical A and the green channel stores chemical B. The update pass reads both channels, computes a 9-point Laplacian for each, and applies the Gray-Scott equations. The result is written to the blue and alpha channels. A second compute pass copies the blue and alpha channels back to red and green. The image pass visualises B as the pattern and A as a faint background tint.
Try changing
Move uFeed and uKill together to explore the classic Gray-Scott parameter map. Change the diffusion rates to make the patterns larger or fuzzier. Seed B at the corners instead of the centre. Add a mouse-driven output.g plus 0.1 stroke to paint new B into the field.
Using it in a game
Reaction-diffusion is used for procedural skin, rust, moss, coral, and abstract texture synthesis. It is also a close cousin of the texture-synthesis techniques used for weathering masks and erosion maps. The big idea is that local rules plus diffusion produce large-scale structure.
Explore the techniques
Continue with curated explanations and progressively related examples.
Discussion