Skip to content

Particle fountain

Download PNG

A particle system that never touches the CPU. Each frame a compute shader advances 2048 particles (gravity, position, lifetime) from one buffer into another and respawns the dead ones at the nozzle with a fresh random velocity; the render pass then binds the buffer just written as an instance-stepped vertex buffer and draws one point per particle, blended additively so overlapping sparks glow. The poster is the first frame, before the fountain has risen; press Play.

examples/samples/20_particle_fountain.sjon
; Particle fountain: 2048 GPU-simulated particles shot upward from a point,
; falling under gravity and respawning when their lifetime ends; drawn as
; points that fade yellow → orange → dark red and blend additively (src-alpha
; over one). A compute `(init …)` seeds the `:pool 2` particle buffer once with
; staggered lifetimes; each frame a compute step reads one pool variant and
; writes the other (`:ping-pong` bind-group entries selected with
; `:bind-groups-pool-offsets`), then the render pass binds the variant just
; written as its instance-step vertex buffer (`:vertex-buffers-pool-offsets
; [1]`) and draws one point per instance with `point-list` topology.
;
; Particle layout: pos(3) life(1) vel(3) maxLife(1) = 32 bytes. Both dispatches
; use `:workgroups [32]` (32 × 64 threads = 2048 particles).
(define :name NUM_PARTICLES :value 2048)
(buffer :name particleBuffer :size (* NUM_PARTICLES 8 4) :usage [vertex storage] :pool 2)
(buffer :name uniforms :size 16 :usage [uniform copy-dst])
(queue :name writeUniforms
(write-buffer :buffer uniforms :offset 0 :data pngine-inputs))
(init :name initParticles :buffer particleBuffer :module initShader :workgroups [32])
(shader-module :name initShader :code """
struct Particle {
pos: vec3f,
life: f32,
vel: vec3f,
maxLife: f32,
}
struct Particles { data: array<Particle> }
@binding(0) @group(0) var<storage, read_write> particles: Particles;
fn hash(n: u32) -> f32 {
var x = n;
x = ((x >> 16u) ^ x) * 0x45d9f3bu;
x = ((x >> 16u) ^ x) * 0x45d9f3bu;
x = (x >> 16u) ^ x;
return f32(x) / f32(0xffffffffu);
}
const NUM: u32 = 2048u;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3u) {
let i = id.x;
if (i >= NUM) { return; }
// Stagger initial lifetimes
particles.data[i].life = -hash(i * 13u) * 3.0;
particles.data[i].maxLife = 1.5 + hash(i * 17u) * 1.5;
particles.data[i].pos = vec3f(0.0, -0.5, 0.0);
particles.data[i].vel = vec3f(0.0);
}
""")
(shader-module :name stepShader :code """
struct Uniforms {
time: f32,
width: f32,
height: f32,
aspect: f32,
}
@group(0) @binding(0) var<uniform> u: Uniforms;
struct Particle {
pos: vec3f,
life: f32,
vel: vec3f,
maxLife: f32,
}
struct Particles { data: array<Particle> }
@group(0) @binding(1) var<storage, read> particlesIn: Particles;
@group(0) @binding(2) var<storage, read_write> particlesOut: Particles;
fn hash(n: u32) -> f32 {
var x = n;
x = ((x >> 16u) ^ x) * 0x45d9f3bu;
x = ((x >> 16u) ^ x) * 0x45d9f3bu;
x = (x >> 16u) ^ x;
return f32(x) / f32(0xffffffffu);
}
const PI: f32 = 3.14159265359;
const DT: f32 = 0.016;
const GRAVITY: f32 = -1.5;
const NUM: u32 = 2048u;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3u) {
let i = id.x;
if (i >= NUM) { return; }
var p = particlesIn.data[i];
p.life += DT;
if (p.life <= 0.0 || p.life > p.maxLife) {
// Respawn at fountain source
p.pos = vec3f(0.0, -0.5, 0.0);
let seed = u32(u.time * 1000.0) + i;
let angle = hash(seed * 7u) * PI * 2.0;
let spread = hash(seed * 11u) * 0.3;
let upSpeed = 1.8 + hash(seed * 13u) * 0.8;
p.vel = vec3f(
cos(angle) * spread,
upSpeed,
sin(angle) * spread
);
p.life = 0.001;
p.maxLife = 1.2 + hash(seed * 17u) * 1.0;
} else {
// Physics
p.vel.y += GRAVITY * DT;
p.pos += p.vel * DT;
}
particlesOut.data[i] = p;
}
""")
(compute-pipeline :name stepPipeline :layout auto (compute :module stepShader :entry main))
(bind-group :name stepBindGroup :layout stepPipeline :group 0 :pool 2
(entry :binding 0 :buffer uniforms)
(entry :binding 1 :buffer particleBuffer :ping-pong 0)
(entry :binding 2 :buffer particleBuffer :ping-pong 1))
(compute-pass :name stepPass
:pipeline stepPipeline
:bind-groups [stepBindGroup]
:bind-groups-pool-offsets [0]
(dispatch :workgroups [32]))
(shader-module :name renderShader :code """
struct Uniforms {
time: f32,
width: f32,
height: f32,
aspect: f32,
}
@group(0) @binding(0) var<uniform> u: Uniforms;
struct VertexOutput {
@builtin(position) pos: vec4f,
@location(0) life: f32,
@location(1) maxLife: f32,
}
@vertex
fn vs(
@location(0) position: vec3f,
@location(1) life: f32,
@location(2) velocity: vec3f,
@location(3) maxLife: f32
) -> VertexOutput {
var out: VertexOutput;
// Hide waiting particles
var pos = position;
if (life <= 0.0) {
pos = vec3f(100.0);
}
// Simple perspective
let z = pos.z + 2.5;
let projX = pos.x / z / u.aspect;
let projY = pos.y / z;
out.pos = vec4f(projX, projY, 0.5, 1.0);
out.life = life;
out.maxLife = maxLife;
return out;
}
@fragment
fn fs(in: VertexOutput) -> @location(0) vec4f {
let t = clamp(in.life / in.maxLife, 0.0, 1.0);
// Yellow -> Orange -> Red -> Dark red
var color = mix(
vec3f(1.0, 0.9, 0.3),
vec3f(1.0, 0.4, 0.1),
t
);
color = mix(color, vec3f(0.3, 0.0, 0.0), smoothstep(0.7, 1.0, t));
// Fade out
let alpha = 1.0 - smoothstep(0.6, 1.0, t);
return vec4f(color * alpha, alpha);
}
""")
(render-pipeline :name renderPipeline
:layout auto
(vertex :module renderShader :entry vs
(vertex-buffer :array-stride 32 :step-mode instance
(attribute :shader-location 0 :offset 0 :format float32x3)
(attribute :shader-location 1 :offset 12 :format float32)
(attribute :shader-location 2 :offset 16 :format float32x3)
(attribute :shader-location 3 :offset 28 :format float32)))
(fragment :module renderShader :entry fs
(target :format preferred-canvas-format
(blend
(color :src-factor src-alpha :dst-factor one)
(alpha :src-factor one :dst-factor one))))
(primitive :topology point-list))
(bind-group :name renderBindGroup :layout renderPipeline :group 0
(entry :binding 0 :buffer uniforms))
; drawPass binds the ping-pong particle buffer as its instance vertex buffer.
; The pipeline declares a step-mode-instance vertex layout that the vertex stage
; reads via @location(0..3), so vertex buffer 0 must be set.
; `:vertex-buffers-pool-offsets [1]` selects the variant the compute step just
; wrote: stepBindGroup variant 0 reads particleBuffer[0] and writes [1] at
; frame 0, so offset 1 renders the fresh output.
(render-pass :name drawPass
(color-attachment :view context-current-texture :clear-value [0.02 0.02 0.05 1] :load-op clear :store-op store)
:pipeline renderPipeline
:vertex-buffers [particleBuffer]
:vertex-buffers-pool-offsets [1]
:bind-groups [renderBindGroup]
:bind-groups-pool-offsets [0]
(draw :vertex-count 1 :instance-count NUM_PARTICLES))
(frame :name main
:init [initParticles]
:perform [writeUniforms stepPass drawPass])

One form of SJON (the S-expression format pngine compiles; each form is one WebGPU resource or operation) declares the whole particle system’s memory: (buffer :name particleBuffer :size (* NUM_PARTICLES 8 4) :usage [vertex storage] :pool 2) holds 32-byte particles, pos (3) life (1) vel (3) maxLife (1), twice over (:pool 2). It is written by the init shader and the step shader as storage, and read by the vertex stage as a vertex buffer, so it carries both usages. (init :name initParticles … :workgroups [32]) seeds it once: I give every particle the nozzle position (0, -0.5, 0), zero velocity and a negative starting life, so they come alive staggered over the first three seconds instead of all at once.

An exercise: change the init shader’s life = -hash(i * 13u) * 3.0 to life = 0.0 and recompile. Every particle is then alive on the first frame, sitting at the nozzle with no velocity, so the fountain opens as one falling clump, and the stagger only re-establishes itself as the differing maxLife values pull the respawns apart.

stepShader (WGSL, WebGPU’s shading language) reads particlesIn (storage, read) and writes particlesOut (storage, read_write); the bind group is :pool 2 with :ping-pong 0 / :ping-pong 1, and :bind-groups-pool-offsets [0] on the pass lets the runtime alternate variants every frame. Per particle, life += DT; if it is not yet born or past maxLife, it respawns at the nozzle with a hashed direction (spread up to 0.3 sideways, 1.8 to 2.6 upward) and a fresh lifetime, seeded from u.time so no two bursts repeat; otherwise gravity -1.5 pulls on vel.y and the position integrates. The pass’s (dispatch :workgroups [32]) × 64 threads covers the 2048 particles.

This is the sample’s distinctive line: :vertex-buffers [particleBuffer] :vertex-buffers-pool-offsets [1]. The render pass binds the pooled buffer as vertex buffer 0 and selects pool variant offset 1: on the frame where the step reads variant 0 and writes variant 1, the draw reads variant 1, the fresh output. The pipeline’s single (vertex-buffer …) is :step-mode instance at a 32-byte stride, so (draw :vertex-count 1 :instance-count NUM_PARTICLES) gives each instance one vertex whose attributes are that particle’s record; with (primitive :topology point-list) each vertex is one pixel-sized point.

vs moves particles with life <= 0 far off-screen (they are waiting to be born) and projects the rest with a manual perspective divide. fs fades the colour yellow → orange → dark red over the particle’s life and drops the alpha to 0 over the last 40%.

(blend (color :src-factor src-alpha :dst-factor one) (alpha :src-factor one :dst-factor one)) adds the source, weighted by its alpha, onto whatever is already there. The returned colour is pre-multiplied by alpha, so dying particles fade out rather than darken, and where several points overlap the sum brightens towards white.

What the sample uses WebGPU WGSL
A buffer used as storage and as a vertex buffer buffer usage, STORAGE, VERTEX storage address space, structure member layout
Point-list topology "point-list", point rasterization
One vertex per instance draw(), GPUVertexStepMode, setVertexBuffer() @location inputs
Additive blending blend state, GPUBlendFactor, "one"
The compute step compute passes, dispatchWorkgroups() @workgroup_size, global_invocation_id, if statement
Fade and hashing mix, smoothstep, clamp, bit expressions
  • Sprite rendering blends source-over instead of additively; Wireframe cube uses the other non-triangle topology.
  • Collatz search is the counterpoint to this page’s opening line: a program that only computes, and whose one output is the four numbers it sends back to the CPU.
  • Upstream: the WebGPU Samples particles sample is the fuller version of this idea (billboarded quads, a probability-map spawner); pngine’s port is examples/webgpu_particles.sjon in the engine repository.
  • Forms: (buffer …) (:pool), (render-pass …) (:vertex-buffers-pool-offsets), (render-pipeline …) ((blend …), (primitive …)), (compute-pass …).