Skip to main content
GameDev.net gamedev.net

GPU Particle Field

by GameDev.net · WebGPU Compute (WGSL) · 25 Aug 2026

Particles live in the first pixels of a storage buffer. A compute pass updates their positions and velocities, and an image pass renders them by finding the nearest particle to every fragment.

What it demonstrates

Using a storage buffer as an object list, not just a pixel grid. Compute-side integration with noise and mouse interaction. Fragment-side rendering of point data as a distance field.

Source Revision 3

Author notes are linked to specific lines.

Common1
1 const PARTICLE_COUNT: u32 = 128u;
2
3 fn cellIndex(gid: vec2<u32>) -> u32 {
4 return gid.y * u32(uniforms.resolution.x) + gid.x;
5 }
6
7 fn hash2(p: vec2<u32>) -> f32 {
8 var h = p.x * 374761393u + p.y * 668265263u;
9 h = (h ^ (h >> 13u)) * 1274126177u;
10 return f32(h ^ (h >> 16u)) / 4294967295.0;
11 }
12
13 fn noise2(p: vec2<f32>) -> f32 {
14 let i = floor(p);
15 var f = fract(p);
16 f = f * f * (3.0 - 2.0 * f);
17 let a = hash2(vec2<u32>(i));
18 let b = hash2(vec2<u32>(i + vec2<f32>(1.0, 0.0)));
19 let c = hash2(vec2<u32>(i + vec2<f32>(0.0, 1.0)));
20 let d = hash2(vec2<u32>(i + vec2<f32>(1.0, 1.0)));
21 return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
22 }
23
Compute (integrate)1
1 // Particle field: each particle stores position and velocity in the first
2 // PARTICLE_COUNT pixels of the output buffer. The compute pass updates them;
3 // the image pass renders them as glowing points.
4
5 @compute @workgroup_size(1, 1, 1)
6 fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
7 let id = gid.x;
8 if (id >= PARTICLE_COUNT) {
9 return;
10 }
11
12 var pos: vec2<f32>;
13 var vel: vec2<f32>;
14
15 if (uniforms.frame == 0u) {
16 pos = vec2<f32>(hash2(vec2<u32>(id, 0u)), hash2(vec2<u32>(id, 1u)));
17 let angle = hash2(vec2<u32>(id, 2u)) * 6.283185;
18 let speed = 0.05 + hash2(vec2<u32>(id, 3u)) * 0.15;
19 vel = vec2<f32>(cos(angle), sin(angle)) * speed;
20 } else {
21 pos = output[id].xy;
22 vel = output[id].zw;
23 }
24
25 let t = uniforms.time * 0.3;
26 let noise = vec2<f32>(
27 noise2(pos * 3.0 + vec2<f32>(t, 0.0)),
28 noise2(pos * 3.0 + vec2<f32>(0.0, t))
29 );
30 vel = vel + noise * 0.002;
31
32 // Gentle mouse attraction when the button is held.
33 let mouse = uniforms.mouse.xy / uniforms.resolution;
34 let mouseDown = uniforms.mouse.z > 0.0 || uniforms.mouse.w > 0.0;
35 if (mouseDown) {
36 let dir = mouse - pos;
37 vel = vel + normalize(dir + vec2<f32>(0.0001)) * 0.0005;
38 }
39
40 pos = pos + vel;
41
42 // Wrap around the edges.
43 pos = fract(pos);
44
45 // Soft damping to keep the field from exploding.
46 vel = vel * 0.999;
47
48 output[id] = vec4<f32>(pos, vel);
49 }
50
Main Image1
1 // Render the particle field as a distance-based glow. The particle state is
2 // stored in the first PARTICLE_COUNT pixels of the output buffer; the
3 // fragment shader finds the nearest particle and colours by its velocity.
4
5 @fragment
6 fn main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
7 let uv = pos.xy / uniforms.resolution;
8
9 var nearest = 9999.0;
10 var nearestVel = vec2<f32>(0.0);
11 for (var i = 0u; i < PARTICLE_COUNT; i = i + 1u) {
12 let p = output[i].xy;
13 let vel = output[i].zw;
14 let d = length(p - uv);
15 if (d < nearest) {
16 nearest = d;
17 nearestVel = vel;
18 }
19 }
20
21 let glow = 1.0 / (1.0 + 80.0 * nearest);
22 let dir = nearestVel / (length(nearestVel) + 0.001);
23 let colour = vec3<f32>(dir * 0.5 + 0.5, 1.0);
24 let bg = vec3<f32>(0.03, 0.04, 0.06);
25 let col = mix(bg, colour, clamp(glow, 0.0, 1.0));
26 return vec4<f32>(col, 1.0);
27 }
28

Learn from this shader

How it works

The compute pass runs one invocation per particle. Each particle reads its previous position and velocity, adds a curl-like noise force, optionally pulls toward the mouse, and integrates forward with wrap-around boundaries. The image pass iterates over all particles for every fragment, finds the nearest particle, and draws a velocity-coloured glow.

Try changing

Change PARTICLE_COUNT in common.wgsl and the matching dispatch in the manifest to add more particles. Replace the noise force with a radial force from the centre. Add a repulsive force between nearby particles in the compute pass, which becomes an O(n squared) particle system. Make the image pass render particles as small circles instead of a nearest-neighbour glow.

Using it in a game

This is the basis of GPU particle systems: sparks, dust, magic, and simple crowds. Production engines usually use compute to update positions, then render them as instanced billboards, not a fragment distance field, but the distance-field approach is a quick way to prototype the motion before committing to geometry.

Explore the techniques

Continue with curated explanations and progressively related examples.

LicenseMIT
Views1
Forks0

Discussion

Loading comments...