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.
Common1
const PARTICLE_COUNT: u32 = 128u;
fn cellIndex(gid: vec2<u32>) -> u32 {
return gid.y * u32(uniforms.resolution.x) + gid.x;
}
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;
}
fn noise2(p: vec2<f32>) -> f32 {
let i = floor(p);
var f = fract(p);
f = f * f * (3.0 - 2.0 * f);
let a = hash2(vec2<u32>(i));
let b = hash2(vec2<u32>(i + vec2<f32>(1.0, 0.0)));
let c = hash2(vec2<u32>(i + vec2<f32>(0.0, 1.0)));
let d = hash2(vec2<u32>(i + vec2<f32>(1.0, 1.0)));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
Compute (integrate)1
// Particle field: each particle stores position and velocity in the first
// PARTICLE_COUNT pixels of the output buffer. The compute pass updates them;
// the image pass renders them as glowing points.
@compute @workgroup_size(1, 1, 1)
fn main(@builtin(global_invocation_id) gid: vec3<u32>) {
let id = gid.x;
if (id >= PARTICLE_COUNT) {
return;
}
var pos: vec2<f32>;
var vel: vec2<f32>;
if (uniforms.frame == 0u) {
pos = vec2<f32>(hash2(vec2<u32>(id, 0u)), hash2(vec2<u32>(id, 1u)));
let angle = hash2(vec2<u32>(id, 2u)) * 6.283185;
let speed = 0.05 + hash2(vec2<u32>(id, 3u)) * 0.15;
vel = vec2<f32>(cos(angle), sin(angle)) * speed;
} else {
pos = output[id].xy;
vel = output[id].zw;
}
let t = uniforms.time * 0.3;
let noise = vec2<f32>(
noise2(pos * 3.0 + vec2<f32>(t, 0.0)),
noise2(pos * 3.0 + vec2<f32>(0.0, t))
);
vel = vel + noise * 0.002;
// Gentle mouse attraction when the button is held.
let mouse = uniforms.mouse.xy / uniforms.resolution;
let mouseDown = uniforms.mouse.z > 0.0 || uniforms.mouse.w > 0.0;
if (mouseDown) {
let dir = mouse - pos;
vel = vel + normalize(dir + vec2<f32>(0.0001)) * 0.0005;
}
pos = pos + vel;
// Wrap around the edges.
pos = fract(pos);
// Soft damping to keep the field from exploding.
vel = vel * 0.999;
output[id] = vec4<f32>(pos, vel);
}
Main Image1
// Render the particle field as a distance-based glow. The particle state is
// stored in the first PARTICLE_COUNT pixels of the output buffer; the
// fragment shader finds the nearest particle and colours by its velocity.
@fragment
fn main(@builtin(position) pos: vec4<f32>) -> @location(0) vec4<f32> {
let uv = pos.xy / uniforms.resolution;
var nearest = 9999.0;
var nearestVel = vec2<f32>(0.0);
for (var i = 0u; i < PARTICLE_COUNT; i = i + 1u) {
let p = output[i].xy;
let vel = output[i].zw;
let d = length(p - uv);
if (d < nearest) {
nearest = d;
nearestVel = vel;
}
}
let glow = 1.0 / (1.0 + 80.0 * nearest);
let dir = nearestVel / (length(nearestVel) + 0.001);
let colour = vec3<f32>(dir * 0.5 + 0.5, 1.0);
let bg = vec3<f32>(0.03, 0.04, 0.06);
let col = mix(bg, colour, clamp(glow, 0.0, 1.0));
return vec4<f32>(col, 1.0);
}
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.
Discussion