Skip to main content
GameDev.net gamedev.net

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.

Source Revision 4

Author notes are linked to specific lines.

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
1 // Gray-Scott reaction-diffusion. The update pass reads the current A/B values
2 // from the red/green channels, computes the next state, and writes the next
3 // state into the blue/alpha channels. A separate copy pass commits it back.
4
5 const uFeed: f32 = 0.055;
6 const uKill: f32 = 0.062;
7 const uDiffA: f32 = 1.0;
8 const uDiffB: f32 = 0.5;
9
10 @compute @workgroup_size(8, 8, 1)
11 fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
12 if (!inBounds(gid.xy)) {
13 return;
14 }
15 let idx = cellIndex(gid.xy);
16
17 if (uniforms.frame == 0u) {
18 let uv = vec2<f32>(gid.xy) / uniforms.resolution;
19 let a = 1.0;
20 let b = 0.0;
21 // A small block of B in the centre seeds the pattern.
22 let center = abs(uv - 0.5) < vec2<f32>(0.06);
23 let bSeed = select(b, 1.0, center.x && center.y);
24 // A little noise helps break symmetry.
25 let bNoisy = bSeed + hash2(gid.xy) * 0.02;
26 output[idx].r = a;
27 output[idx].g = bNoisy;
28 output[idx].b = a;
29 output[idx].a = bNoisy;
30 return;
31 }
32
33 let a = output[idx].r;
34 let b = output[idx].g;
35 let abb = a * b * b;
36
37 let lapA = laplacian(gid.xy, 0u);
38 let lapB = laplacian(gid.xy, 1u);
39
40 let da = uDiffA * lapA - abb + uFeed * (1.0 - a);
41 let db = uDiffB * lapB + abb - (uKill + uFeed) * b;
42
43 let dt = 1.0;
44 let nextA = clamp(a + da * dt, 0.0, 1.0);
45 let nextB = clamp(b + db * dt, 0.0, 1.0);
46
47 output[idx].b = nextA;
48 output[idx].a = nextB;
49 }
50
Compute (copy)1
1 // Commit the swapped buffer. Blue holds the next A, alpha holds the next B;
2 // copy them back to red and green for the next frame.
3
4 @compute @workgroup_size(8, 8, 1)
5 fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
6 if (!inBounds(gid.xy)) {
7 return;
8 }
9 let idx = cellIndex(gid.xy);
10 output[idx].r = output[idx].b;
11 output[idx].g = output[idx].a;
12 }
13
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.

LicenseMIT
Views5
Forks0

Discussion

Loading comments...