Particle Swarm
Two hundred thousand sparks in a buffer on the GPU, moved sixty times a second by a compute kernel, chase their places on crossing spiral arms around a leader that wanders the picture; they burn down and are born again beside it, leave streaks behind them, and buzz like a swarm of bees that follows the leader across the stereo field.
· Particles & Simulation · Compute shader
Teaches Compute shaders and GPU buffers
Builds on 01 · Flowing Color Field, 05 · Cracked Clay (integer hash, sound noise), 09 · Ember Tunnel
Watch
The idea
Every episode so far was a fragment shader: a small program that runs once for each pixel and works out its colour from scratch, with no memory of the frame before. A swarm doesn't fit that. Each spark has to remember where it is and how fast it is going, and every frame moves it on from there.
So this episode keeps its sparks in a buffer on the GPU: 200,000 of them, each a few numbers. A compute shader, a GPU program that isn't drawing anything, runs once for every spark at every step, sixty steps a second, reads its numbers and writes them back changed. All 200,000 run at once, the way a fragment shader runs for all pixels at once. Then the host draws each spark as a small dot, the dots add up in an image, and shade turns that image into the picture.
In Ray Kiln, a // @particles 200000 line makes an episode a compute episode. The episode says what a spark is (struct Particle), where each starts (spawn), what one step does to it (update), where and how big its dot is (place), and what colour (ink). The steps always start from spawn and run at sixty a second whatever the frame rate, so a frame at a given moment is always the same picture, in the app, in the videos and in the browser.
It is built in five steps, and each step is a shader you can run on its own.
Stage 1 · A buffer of sparks
A spark is just its position: two floats, eight bytes, so the buffer is 200,000 × 8 bytes. spawn runs once for each spark, and the only thing it is told is id, its place in the buffer, from 0 to 199,999. Hashing the id with episode 5's integer hash gives each spark its own random angle and distance, and taking the square root of the distance spreads them evenly over a disc instead of crowding the middle.
update does nothing yet. Positions live in a space one unit tall from the middle and as wide as the picture, so place divides x by the aspect ratio before turning it into the picture's uv, and the disc stays round. ink gives each dot a soft orange edge; at runs from -1 to 1 across the dot. Where dots overlap, their colours add, and shade rolls the total off with c / (1 + c) so it never clips.
stage-1-buffer.metal
// Stage 1 · A buffer of sparks
// No pixel is drawn by a formula here. Two hundred thousand sparks live in a buffer on the GPU, each one a few numbers,
// and the host draws every one of them as a dot.
//
// @particles 200000
// A spark is just its position.
struct Particle {
float2 position;
};
// Episode 5's integer hash.
float hashU(uint n) {
n = (n << 13u) ^ n;
n = n * (n * n * 15731u + 789221u) + 1376312589u;
return float(n & 0x7fffffffu) / float(0x7fffffff);
}
// spawn runs once for each spark. Its id is its place in the buffer; hashing it scatters the sparks over a disc.
Particle spawn(uint id, constant Params& p) {
Particle q;
float a = 6.2831853 * hashU(id * 4u);
float r = 0.9 * sqrt(hashU(id * 4u + 1u));
q.position = float2(cos(a), sin(a)) * r;
return q;
}
// update runs for every spark at every step, sixty a second. For now it does nothing.
void update(thread Particle& q, uint id,
constant Uniforms& u, constant Params& p) {
}
// place says where the host draws a spark: from its position to the picture's uv, and a dot size, as a fraction of
// the picture's height.
float3 place(Particle q, constant Uniforms& u, constant Params& p) {
float aspect = u.resolution.x / u.resolution.y;
float2 uv = q.position / float2(aspect, 1.0) * 0.5 + 0.5;
return float3(uv, 0.004);
}
// ink is the dot's colour; at runs from -1 to 1 across the dot, so its edge can be soft.
float4 ink(Particle q, float2 at, constant Uniforms& u, constant Params& p) {
float edge = smoothstep(1.0, 0.0, length(at));
return float4(float3(1.0, 0.59, 0.20) * edge * 0.25, 1.0);
}
// The dots add up in an image, and shade reads it like any picture.
float4 shade(float2 uv, constant Uniforms& u, constant Params& p,
Trails trails) {
float3 c = trails.at(uv).rgb;
return float4(c / (1.0 + c), 1.0);
}Stage 2 · One step at a time
Now a spark has a velocity too. Each step is a sixtieth of a second (u.timeDelta), and update does what any simple physics does in a step:
- Add up the forces. A spring pulls the spark toward the nearest point on a ring of radius 0.5, harder the farther away it is. A small random shove, hashed from the spark's
idand the step's numberu.frame, is new at every step, so no two sparks move as one. - Change the velocity by the forces times the step.
- Lose some speed to drag: the velocity is multiplied by
exp(-drag * dt), the same fall-off as episode 9's fog, but over time instead of distance. - Move by the velocity times the step.
All 200,000 sparks fall onto the ring at once and overshoot it a little before they settle. pull sets how hard the spring is, drag how soon they calm down: at 1 they swing back and forth through the ring; at 6 they slide straight onto it.
stage-2-step.metal10 new lines since stage 1
// Stage 2 · One step at a time
// Give each spark a velocity. At every step, update pulls it toward a ring around the middle, slows it down a
// little, and moves it on: the whole swarm falls onto the ring at once, two hundred thousand sparks in parallel.
//
// @particles 200000
// @param pull 1.0 0.4 3.0 How hard each spark is pulled toward the ring
// @param drag 3.0 1.0 6.0 How quickly the sparks lose speed: loose and wild, or tight and calm
// A spark now keeps its speed too: the buffer holds four floats for each one.
struct Particle {
float2 position;
float2 velocity;
};
// Episode 5's integer hash: a whole number in, a random number from 0 to 1 out.
float hashU(uint n) {
n = (n << 13u) ^ n;
n = n * (n * n * 15731u + 789221u) + 1376312589u;
return float(n & 0x7fffffffu) / float(0x7fffffff);
}
Particle spawn(uint id, constant Params& p) {
Particle q;
float a = 6.2831853 * hashU(id * 4u);
float r = 0.9 * sqrt(hashU(id * 4u + 1u));
q.position = float2(cos(a), sin(a)) * r;
q.velocity = float2(0.0);
return q;
}
// One step of u.timeDelta seconds (a sixtieth): add up the forces, change the velocity by them, lose a little speed
// to drag, then move. The pull is a spring toward the nearest point of the ring. A small random shove, new at every
// step, keeps the sparks from moving as one.
void update(thread Particle& q, uint id,
constant Uniforms& u, constant Params& p) {
float dt = u.timeDelta;
float2 goal = normalize(q.position + 1e-5) * 0.5;
float2 force = (goal - q.position) * p.pull * 6.0;
uint k = id * 2u + u.frame * 7919u;
force += (float2(hashU(k), hashU(k + 1u)) - 0.5) * 0.8;
q.velocity += force * dt;
q.velocity *= exp(-p.drag * dt);
q.position += q.velocity * dt;
}
float3 place(Particle q, constant Uniforms& u, constant Params& p) {
float aspect = u.resolution.x / u.resolution.y;
float2 uv = q.position / float2(aspect, 1.0) * 0.5 + 0.5;
return float3(uv, 0.004);
}
float4 ink(Particle q, float2 at, constant Uniforms& u, constant Params& p) {
float edge = smoothstep(1.0, 0.0, length(at));
return float4(float3(1.0, 0.59, 0.20) * edge * 0.25, 1.0);
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p,
Trails trails) {
float3 c = trails.at(uv).rgb;
return float4(c / (1.0 + c), 1.0);
}Stage 3 · A place of its own
Instead of a ring, each spark chases a place of its own, worked out from its id again at every step: a radius, one of arms spiral arms, and a little scatter. The farther out a place is, the more its arm has turned (radius * 3.0), which bends each arm into a spiral, and the clock turns the whole spiral (u.time * swirl).
A spark now carries a seed, a random number fixed at spawn, and it decides which way its arm turns: half the sparks make spirals that turn one way, half the other, and where they cross they make a lattice. The sparks never sit on their places: the spring and the drag make each one trail a little behind its place as it turns, which is what gives the arms their soft edges.
stage-3-places.metal11 new lines since stage 2
// Stage 3 · A place of its own
// Instead of the middle, each spark chases a place of its own on a spiral arm, from a hash of its id. The arms
// turn, so the sparks chase them round; half turn one way and half the other, and the two spirals cross.
//
// @particles 200000
// @param pull 1.0 0.4 3.0 How hard each spark is pulled toward its place
// @param arms 3.0 1.0 6.0 How many spiral arms each half of the swarm forms
// @param swirl 0.6 0.0 2.0 How fast the arms turn
// @param drag 3.0 1.0 6.0 How quickly the sparks lose speed: loose and wild, or tight and calm
constant float TAU = 6.2831853;
// A random number of its own decides which way a spark's arm turns.
struct Particle {
float2 position;
float2 velocity;
float seed;
};
// Episode 5's integer hash: a whole number in, a random number from 0 to 1 out.
float hashU(uint n) {
n = (n << 13u) ^ n;
n = n * (n * n * 15731u + 789221u) + 1376312589u;
return float(n & 0x7fffffffu) / float(0x7fffffff);
}
Particle spawn(uint id, constant Params& p) {
Particle q;
float a = TAU * hashU(id * 4u);
float r = 0.9 * sqrt(hashU(id * 4u + 1u));
q.position = float2(cos(a), sin(a)) * r;
q.velocity = float2(0.0);
q.seed = hashU(id * 4u + 3u);
return q;
}
// The place: a radius, an arm and a little scatter from the id. The farther out, the more the arm has turned, which
// makes it a spiral; the clock turns the whole spiral.
void update(thread Particle& q, uint id,
constant Uniforms& u, constant Params& p) {
float dt = u.timeDelta;
float spin = q.seed < 0.5 ? 1.0 : -1.0;
float radius = 0.06 + 0.7 * pow(hashU(id * 9u + 5u), 1.2);
float arms = max(floor(p.arms + 0.5), 1.0);
float arm = floor(hashU(id * 9u + 6u) * arms) * TAU / arms;
float scatter = (hashU(id * 9u + 7u) - 0.5) * 0.18;
float angle = arm + scatter + spin * (radius * 3.0 + u.time * p.swirl);
float2 goal = radius * float2(cos(angle), sin(angle));
// Pulled toward its place like a spring, harder the farther it is.
float2 force = (goal - q.position) * p.pull * 6.0;
uint k = id * 2u + u.frame * 7919u;
force += (float2(hashU(k), hashU(k + 1u)) - 0.5) * 0.8;
q.velocity += force * dt;
q.velocity *= exp(-p.drag * dt);
q.position += q.velocity * dt;
}
float3 place(Particle q, constant Uniforms& u, constant Params& p) {
float aspect = u.resolution.x / u.resolution.y;
float2 uv = q.position / float2(aspect, 1.0) * 0.5 + 0.5;
return float3(uv, 0.004);
}
float4 ink(Particle q, float2 at, constant Uniforms& u, constant Params& p) {
float edge = smoothstep(1.0, 0.0, length(at));
return float4(float3(1.0, 0.59, 0.20) * edge * 0.25, 1.0);
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p,
Trails trails) {
float3 c = trails.at(uv).rgb;
return float4(c / (1.0 + c), 1.0);
}2 lines from stage 2 that are gone
float a = 6.2831853 * hashU(id * 4u);
⋯
float2 goal = normalize(q.position + 1e-5) * 0.5;Stage 4 · Trails and a leader
Two changes make it move like a swarm.
- Trails. Without
fade, every step starts from a black image. With it, each step starts from the last step's image timestrails, so every spark leaves a streak behind it. Keeping more of each step would also make the picture brighter, soinkdims each dot by(1 - trails) / 0.15:trailschanges how long the streaks are, not how bright the swarm is. - A leader. The middle of the swarm is no longer fixed.
leaderis a slow loop of sines across the picture, at speeds that don't line up, so it never quite repeats; each spark's place is measured from it, and the arms are dragged along behind it.
The colour now comes from the spark's speed on episode 1's kiln palette: the sparks that are catching up with their places burn hotter. shade lays them over a dark warm ground.
stage-4-trails.metal36 new lines since stage 3
// Stage 4 · Trails and a leader
// Keep most of the last step's image instead of starting from black, and every spark draws a streak. The middle of
// the swarm becomes a leader that wanders around the picture, and each spark's colour comes from its speed.
//
// @particles 200000
// @param speed 0.5 0.0 2.0 How fast the leader wanders
// @param pull 1.0 0.4 3.0 How hard each spark is pulled toward its place
// @param arms 3.0 1.0 6.0 How many spiral arms each half of the swarm forms
// @param swirl 0.6 0.0 2.0 How fast the arms turn
// @param drag 3.0 1.0 6.0 How quickly the sparks lose speed: loose and wild, or tight and calm
// @param trails 0.85 0.5 0.95 How much of the last image is left at each step: short sparks or long streaks
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
constant float TAU = 6.2831853;
struct Particle {
float2 position;
float2 velocity;
float seed;
};
// Episode 5's integer hash: a whole number in, a random number from 0 to 1 out.
float hashU(uint n) {
n = (n << 13u) ^ n;
n = n * (n * n * 15731u + 789221u) + 1376312589u;
return float(n & 0x7fffffffu) / float(0x7fffffff);
}
// The leader: a slow loop of sines, never quite the same twice, across most of the picture.
float2 leader(float t, float aspect) {
return float2(aspect * (0.30 * sin(0.53 * t) + 0.10 * sin(1.31 * t + 1.0)),
0.24 * sin(0.71 * t + 0.6) + 0.08 * sin(1.67 * t + 2.0));
}
// Where the leader is at a step: the clock scaled by speed. At speed 0 it stays put.
float2 leaderAt(float time, float aspect, constant Params& p) {
return leader(time * p.speed + 3.0, aspect);
}
Particle spawn(uint id, constant Params& p) {
Particle q;
float a = TAU * hashU(id * 4u);
float r = 0.9 * sqrt(hashU(id * 4u + 1u));
q.position = float2(cos(a), sin(a)) * r;
q.velocity = float2(0.0);
q.seed = hashU(id * 4u + 3u);
return q;
}
// The same places as before, now around the leader.
void update(thread Particle& q, uint id,
constant Uniforms& u, constant Params& p) {
float dt = u.timeDelta;
float aspect = u.resolution.x / u.resolution.y;
float2 target = leaderAt(u.time, aspect, p);
float spin = q.seed < 0.5 ? 1.0 : -1.0;
float radius = 0.06 + 0.7 * pow(hashU(id * 9u + 5u), 1.2);
float arms = max(floor(p.arms + 0.5), 1.0);
float arm = floor(hashU(id * 9u + 6u) * arms) * TAU / arms;
float scatter = (hashU(id * 9u + 7u) - 0.5) * 0.18;
float angle = arm + scatter + spin * (radius * 3.0 + u.time * p.swirl);
float2 goal = target + radius * float2(cos(angle), sin(angle));
float2 force = (goal - q.position) * p.pull * 6.0;
uint k = id * 2u + u.frame * 7919u;
force += (float2(hashU(k), hashU(k + 1u)) - 0.5) * 0.8;
q.velocity += force * dt;
q.velocity *= exp(-p.drag * dt);
q.position += q.velocity * dt;
}
float3 place(Particle q, constant Uniforms& u, constant Params& p) {
float aspect = u.resolution.x / u.resolution.y;
float2 uv = q.position / float2(aspect, 1.0) * 0.5 + 0.5;
return float3(uv, 0.0035);
}
// The kiln palette from episode 1.
float3 kiln(float t) {
float3 c0 = float3(0.020, 0.012, 0.020);
float3 c1 = float3(0.280, 0.040, 0.030);
float3 c2 = float3(0.880, 0.260, 0.050);
float3 c3 = float3(1.000, 0.680, 0.200);
float3 c4 = float3(1.000, 0.970, 0.840);
t = clamp(t, 0.0, 1.0);
float3 c = mix(c0, c1, smoothstep(0.00, 0.22, t));
c = mix(c, c2, smoothstep(0.22, 0.50, t));
c = mix(c, c3, smoothstep(0.50, 0.78, t));
c = mix(c, c4, smoothstep(0.78, 1.00, t));
return c;
}
// A spark's colour from its speed on the kiln palette: the fast ones burn hotter.
// Longer trails keep more of every step, so each dot is dimmer by as much: the trails change length, not brightness.
float4 ink(Particle q, float2 at, constant Uniforms& u, constant Params& p) {
float fast = min(length(q.velocity) * 0.8, 1.0);
float t = 0.45 + 0.35 * fast + (p.heat - 0.5) * 0.4;
float edge = smoothstep(1.0, 0.0, length(at));
float keep = (1.0 - p.trails) / 0.15;
return float4(kiln(t) * edge * 0.09 * keep * 0.8, 1.0);
}
// What is left of the last step's image: most of it, so every spark leaves a streak behind it.
float4 fade(float2 uv, Trails previous,
constant Uniforms& u, constant Params& p) {
return previous.at(uv) * p.trails;
}
// The picture: the sparks' image, its brightness rolled off so the thick of the swarm glows instead of clipping,
// over a dark warm ground.
float4 shade(float2 uv, constant Uniforms& u, constant Params& p,
Trails trails) {
float3 c = trails.at(uv).rgb;
c = c / (1.0 + c);
float2 q = uv - 0.5;
float3 ground = float3(0.030, 0.018, 0.020) * (1.0 - dot(q, q) * 1.6);
return float4(ground + c * 1.15, 1.0);
}4 lines from stage 3 that are gone
float2 goal = radius * float2(cos(angle), sin(angle));
⋯
return float3(uv, 0.004);
⋯
return float4(float3(1.0, 0.59, 0.20) * edge * 0.25, 1.0);
⋯
return float4(c / (1.0 + c), 1.0);Stage 5 · Particle swarm
The last idea is a life. Each spark keeps life, from 1 down to 0, and spawn starts every one partway through it, so they don't all burn out together. Every step takes a little off, at a rate set by the life slider and the spark's own seed, so some last longer than others.
When a spark burns out, update gives it a new life on the spot: it is put right beside the leader and thrown outward at a random angle and speed, and from there it is pulled back to its place on its arm. So there is always a thin spray of embers leaving the middle of the swarm.
A young spark is a little bigger (place) and brighter and hotter (ink) than an old one, so the swarm is brightest where it is renewed and dims to dull embers toward the end of each life. heat slides the palette, from deep ember to white-hot.
All of this is one buffer of 24 bytes a spark (two float2 and two float), about 4.8 MB, read and written sixty times a second.
the kiln palette and shade(), from the finished shader
// The kiln palette from episode 1.
float3 kiln(float t) {
float3 c0 = float3(0.020, 0.012, 0.020);
float3 c1 = float3(0.280, 0.040, 0.030);
float3 c2 = float3(0.880, 0.260, 0.050);
float3 c3 = float3(1.000, 0.680, 0.200);
float3 c4 = float3(1.000, 0.970, 0.840);
t = clamp(t, 0.0, 1.0);
float3 c = mix(c0, c1, smoothstep(0.00, 0.22, t));
c = mix(c, c2, smoothstep(0.22, 0.50, t));
c = mix(c, c3, smoothstep(0.50, 0.78, t));
c = mix(c, c4, smoothstep(0.78, 1.00, t));
return c;
}
// The picture: the sparks' image, its brightness rolled off so the thick of the swarm glows instead of clipping,
// over a dark warm ground.
float4 shade(float2 uv, constant Uniforms& u, constant Params& p,
Trails trails) {
float3 c = trails.at(uv).rgb;
c = c / (1.0 + c);
float2 q = uv - 0.5;
float3 ground = float3(0.030, 0.018, 0.020) * (1.0 - dot(q, q) * 1.6);
return float4(ground + c * 1.15, 1.0);
}episode-10-particle-swarm.metal, the whole file58 new lines since stage 4
// Ray Kiln · Episode 10 · Particle Swarm
//
// @episode 10 Particle Swarm
// @length 60
// @variation 30
// @variations 3,4,5,8,9,10,11,12
// @sweeps arms, swirl, drag
// @still 24.0
// @teaches Compute shaders and GPU buffers
// @category Compute
// @tags compute shaders, particles, GPU buffers, steering, trails, hash, procedural sound
// @final Particle swarm | Sparks burn out and are born again beside the leader, so the swarm streams embers behind it.
//
// Two hundred thousand sparks chase a point that wanders around the picture. None of them is drawn by a fragment
// shader: each one is a few numbers in a buffer on the GPU, and a compute kernel moves every one of them sixty times a
// second. The host draws each spark as a dot, the dots pile up into an image, and the image fades a little each step,
// so the swarm leaves trails.
// Each @param line below becomes a slider in the host app and a row in the page's parameters table:
// // @param name default min max description
//
// @particles 200000
// @param speed 0.5 0.0 2.0 How fast the leader wanders
// @param pull 1.0 0.4 3.0 How hard each spark is pulled toward its place in the swarm
// @param arms 3.0 1.0 6.0 How many spiral arms each half of the swarm forms
// @param swirl 0.6 0.0 2.0 How fast the arms turn around the leader
// @param drag 3.0 1.0 6.0 How quickly the sparks lose speed: loose and wild, or tight and calm
// @param life 5.0 2.0 10.0 How many seconds a spark burns before it is born again beside the leader
// @param trails 0.85 0.5 0.95 How much of the last image is left at each step: short sparks or long streaks
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
// @param volume 0.8 0.0 1.0 Loudness of the sound
constant float TAU = 6.2831853;
// What one spark is: its place and speed (in a space one unit tall from the middle, as wide as the picture),
// how much of its life is left (1 to 0), and a random number of its own.
struct Particle {
float2 position;
float2 velocity;
float life;
float seed;
};
// Episode 5's integer hash: a whole number in, a random number from 0 to 1 out.
float hashU(uint n) {
n = (n << 13u) ^ n;
n = n * (n * n * 15731u + 789221u) + 1376312589u;
return float(n & 0x7fffffffu) / float(0x7fffffff);
}
// The leader: a slow loop of sines, never quite the same twice, across most of the picture.
float2 leader(float t, float aspect) {
return float2(aspect * (0.30 * sin(0.53 * t) + 0.10 * sin(1.31 * t + 1.0)),
0.24 * sin(0.71 * t + 0.6) + 0.08 * sin(1.67 * t + 2.0));
}
// Where the leader is at a step: the clock scaled by speed. At speed 0 it stays put.
float2 leaderAt(float time, float aspect, constant Params& p) {
return leader(time * p.speed + 3.0, aspect);
}
// Each spark is made once, at step 0: a random place in a disc, still, partway through its life, so they don't all
// burn out at the same moment.
Particle spawn(uint id, constant Params& p) {
Particle q;
float a = TAU * hashU(id * 4u);
float r = 0.9 * sqrt(hashU(id * 4u + 1u));
q.position = float2(cos(a), sin(a)) * r;
q.velocity = float2(0.0);
q.life = hashU(id * 4u + 2u);
q.seed = hashU(id * 4u + 3u);
return q;
}
// One step, for every spark at once: the compute kernel runs this for each of them, sixty times a second.
void update(thread Particle& q, uint id,
constant Uniforms& u, constant Params& p) {
float dt = u.timeDelta;
float aspect = u.resolution.x / u.resolution.y;
float2 target = leaderAt(u.time, aspect, p);
// Each spark has a place of its own around the leader, on one of the spiral arms: a radius from its id, an
// arm, and a little scatter. Half the sparks make arms that turn one way and half the other, crossing each other.
float spin = q.seed < 0.5 ? 1.0 : -1.0;
float radius = 0.06 + 0.7 * pow(hashU(id * 9u + 5u), 1.2);
float arms = max(floor(p.arms + 0.5), 1.0);
float arm = floor(hashU(id * 9u + 6u) * arms) * TAU / arms;
float scatter = (hashU(id * 9u + 7u) - 0.5) * 0.18;
float angle = arm + scatter + spin * (radius * 3.0 + u.time * p.swirl);
float2 goal = target + radius * float2(cos(angle), sin(angle));
// Pulled toward its place like a spring, harder the farther it is.
float2 force = (goal - q.position) * p.pull * 6.0;
// A little shake of its own, new at every step, so no two sparks follow the same path.
uint k = id * 2u + u.frame * 7919u;
force += (float2(hashU(k), hashU(k + 1u)) - 0.5) * 0.8;
q.velocity += force * dt;
q.velocity *= exp(-p.drag * dt);
q.position += q.velocity * dt;
// Burning down. A spark that burns out is born again beside the leader, thrown outward.
q.life -= dt / (p.life * (0.6 + 0.8 * q.seed));
if (q.life <= 0.0) {
float a = TAU * hashU(k + 2u);
float2 out = float2(cos(a), sin(a));
q.position = target + out * 0.04 * hashU(k + 3u);
q.velocity = out * (0.3 + 0.9 * hashU(k + 4u));
q.life = 1.0;
}
}
// Where to draw a spark (the picture's uv) and how big: a young spark is a little larger.
float3 place(Particle q, constant Uniforms& u, constant Params& p) {
float aspect = u.resolution.x / u.resolution.y;
float2 uv = q.position / float2(aspect, 1.0) * 0.5 + 0.5;
return float3(uv, 0.0025 + 0.002 * q.life);
}
// The kiln palette from episode 1.
float3 kiln(float t) {
float3 c0 = float3(0.020, 0.012, 0.020);
float3 c1 = float3(0.280, 0.040, 0.030);
float3 c2 = float3(0.880, 0.260, 0.050);
float3 c3 = float3(1.000, 0.680, 0.200);
float3 c4 = float3(1.000, 0.970, 0.840);
t = clamp(t, 0.0, 1.0);
float3 c = mix(c0, c1, smoothstep(0.00, 0.22, t));
c = mix(c, c2, smoothstep(0.22, 0.50, t));
c = mix(c, c3, smoothstep(0.50, 0.78, t));
c = mix(c, c4, smoothstep(0.78, 1.00, t));
return c;
}
// A spark's colour: hot while it is young and fast, cooling to a dull ember as it burns down. Soft at the edge.
// Longer trails keep more of every step, so each dot is dimmer by as much: the trails change length, not brightness.
float4 ink(Particle q, float2 at, constant Uniforms& u, constant Params& p) {
float fast = min(length(q.velocity) * 0.8, 1.0);
float t = 0.25 + 0.45 * q.life + 0.3 * fast + (p.heat - 0.5) * 0.4;
float edge = smoothstep(1.0, 0.0, length(at));
float keep = (1.0 - p.trails) / 0.15;
return float4(kiln(t) * edge * 0.09 * keep * (0.3 + q.life), 1.0);
}
// What is left of the last step's image: most of it, so every spark leaves a streak behind it.
float4 fade(float2 uv, Trails previous,
constant Uniforms& u, constant Params& p) {
return previous.at(uv) * p.trails;
}
// The picture: the sparks' image, its brightness rolled off so the thick of the swarm glows instead of clipping,
// over a dark warm ground.
float4 shade(float2 uv, constant Uniforms& u, constant Params& p,
Trails trails) {
float3 c = trails.at(uv).rgb;
c = c / (1.0 + c);
float2 q = uv - 0.5;
float3 ground = float3(0.030, 0.018, 0.020) * (1.0 - dot(q, q) * 1.6);
return float4(ground + c * 1.15, 1.0);
}
// A smooth random value from 0 to 1 that changes `rate` times a second: two hashed values eased into each other.
float noise1(float t, float rate, uint salt) {
float x = t * rate;
float i = floor(x);
float f = x - i;
f = f * f * (3.0 - 2.0 * f);
uint n = uint(int(i) + 1000000);
return mix(hashU(n * 31u + salt), hashU((n + 1u) * 31u + salt), f);
}
// The sound follows the leader, the one thing in the swarm that is a function of time alone (the sound has no state,
// so it can't read the sparks): a buzz of many detuned voices, like a swarm of bees, rising as the leader speeds up
// and panned to where it is; a crackle of sparks being born, thicker with shorter lives; and a low drone.
float2 sound(float t, constant Params& p) {
float aspect = 16.0 / 9.0;
float now = t * p.speed + 3.0;
float2 here = leader(now, aspect);
float2 ahead = leader(now + 0.05, aspect);
float pace = clamp(length(ahead - here) / 0.05 * p.speed, 0.0, 2.0);
float pan = clamp(0.5 + 0.35 * here.x / aspect, 0.1, 0.9);
// The buzz: eight voices around D3 (146.8 Hz), each a few cents apart, a little higher as the leader speeds up.
// Tighter drag and more swirl make a brighter buzz.
float base = 146.83 * (1.0 + 0.12 * pace);
float bright = clamp(0.3 + 0.2 * p.swirl + 0.1 * p.drag, 0.2, 1.0);
float buzz = 0.0;
for (int i = 0; i < 8; i++) {
float detune = 1.0 + (hashU(uint(i) * 5u + 11u) - 0.5) * 0.024;
float phase = hashU(uint(i) * 5u + 12u);
float x = fract(base * detune * t + phase);
// A soft sawtooth: a few of its harmonics.
float v = sin(TAU * x) + 0.5 * bright * sin(2.0 * TAU * x)
+ 0.33 * bright * bright * sin(3.0 * TAU * x);
buzz += v;
}
buzz *= 0.03 * (0.5 + 0.5 * pace) * (0.6 + 0.4 * noise1(t, 3.0, 5u));
// The crackle: short clicks of noise at random moments, more of them the shorter a spark's life.
float rate = 18.0 / p.life;
float slot = floor(t * rate);
float into = t * rate - slot;
uint s = uint(int(slot) + 1000000);
float when = hashU(s * 3u);
float age = (into - when) / rate;
float pop = age >= 0.0
? exp(-age * 300.0) * (1.0 - exp(-age * 20000.0)) : 0.0;
float grit = hashU(uint(t * 24000.0) + 77u) - 0.5;
float side = hashU(s * 3u + 1u);
float2 crackle = float2(1.0 - side, side) * pop * grit * 0.5;
// The drone: D2 and A2, slowly breathing.
float drone = sin(TAU * 73.42 * t) + 0.4 * sin(TAU * 110.0 * t + 1.0);
drone *= 0.05 * (0.8 + 0.2 * sin(0.4 * t));
float2 v = float2(1.0 - pan, pan) * 4.4 * buzz
+ crackle * 1.4 + float2(drone) * 3.0;
// tanh rounds off the loudest moments (every slider at its top) instead of letting them clip.
float fadeIn = smoothstep(0.0, 2.0, t);
return tanh(v * 1.2) / 1.2 * fadeIn * p.volume;
}3 lines from stage 4 that are gone
return float3(uv, 0.0035);
⋯
float t = 0.45 + 0.35 * fast + (p.heat - 0.5) * 0.4;
⋯
return float4(kiln(t) * edge * 0.09 * keep * 0.8, 1.0);The sound
A shader's sound is worked out from the time alone, with no memory, so it can't read the sparks. It follows the one thing in the swarm that depends on time alone: the leader.
- The buzz. Eight voices around D3 (146.8 Hz), each tuned a little apart and starting at its own phase, so together they beat like a swarm of bees. Each is a soft sawtooth: a sine and two of its harmonics. The pitch rises a little as the leader speeds up, and the voices are panned to where the leader is across the picture. More
swirland moredragmake the buzz brighter. - The crackle. Short clicks of noise at random moments, each in a random place between the ears: the sparks being born again. There are
18 / lifea second, so shorter lives crackle more. - The drone. A low D at 73.4 Hz and the A above it, slowly breathing.
Everything goes through tanh to round off the loud moments and fades in over the first two seconds. The levels at the defaults are a peak of about -9 dBFS and an average (RMS) of about -21 dBFS.
New in Ray Kiln
The particle readout in the playground says what a compute episode is doing, under its picture: how many particles there are, how many steps a second the swarm is taking, and, in a browser that has GPU timestamp queries, what one step costs the GPU: the compute pass that runs update for every particle, in milliseconds and in nanoseconds a particle, and the pass that draws the dots. The playground takes at most a few steps a frame, so a swarm too heavy for the GPU runs slow instead of freezing the page; when that happens, the readout says how far behind the clock it has fallen. It is free, and shows for any shader with an @particles line.
Try it here: change the @particles 200000 line to 20000, then to 2000000, and watch the numbers. The compute step is the cheap part: every spark runs the same few lines of update, and the GPU does many of them at once, so ten times the sparks costs far less than ten times the time, and the nanoseconds a particle fall. Drawing the dots is what grows. On the Mac this was checked on, two million sparks took under a millisecond a step, while their dots took about ten times that, and the swarm fell behind the clock.
Try this
- Set
armsto 1 for a single pair of crossing arcs, or to 6 for a dense flower. - Set
swirlto 0 and the arms stand still while the leader drags them around; at 2 they spin. - Set
dragto 1 andpullto 3, and the sparks overshoot their places and whip around them. - Set
trailsto 0.95 for long streaks, or to 0.5 for sharp sparks. - Set
lifeto 2 and the middle of the swarm becomes a fountain of embers. - In
update, changeradius * 3.0toradius * 9.0and the arms wind into tight spirals.
Parameters
Pick a parameter and drag the slider. Each frame is the same moment of the shader, with only that one setting changed.
| Parameter | Default | Range | What it does |
|---|---|---|---|
speed | 0.5 | 0.0 to 2.0 | How fast the leader wanders |
pull | 1.0 | 0.4 to 3.0 | How hard each spark is pulled toward its place in the swarm |
arms | 3.0 | 1.0 to 6.0 | How many spiral arms each half of the swarm forms |
swirl | 0.6 | 0.0 to 2.0 | How fast the arms turn around the leader |
drag | 3.0 | 1.0 to 6.0 | How quickly the sparks lose speed: loose and wild, or tight and calm |
life | 5.0 | 2.0 to 10.0 | How many seconds a spark burns before it is born again beside the leader |
trails | 0.85 | 0.5 to 0.95 | How much of the last image is left at each step: short sparks or long streaks |
heat | 0.5 | 0.0 to 1.0 | Slides the palette from deep ember to white-hot |
volume | 0.8 | 0.0 to 1.0 | Loudness of the sound |
More from this shader
The same shader 8 more ways: each from a different moment, with different settings, one after another. Each chapter below says which settings moved most, then lists them all.
Wallpapers
A still from the shader, rendered at full size for a desktop or a phone screen. Free to use as your wallpaper.
Clips to post
Fifteen seconds of the shader with its sound, sized for posting: square for feeds, vertical for stories and short videos. Free to post, ideally with a link to this page.
Made from this episode
The full source
This is the whole episode: the picture, the parameters and the sound.
episode-10-particle-swarm.metal
// Ray Kiln · Episode 10 · Particle Swarm
//
// @episode 10 Particle Swarm
// @length 60
// @variation 30
// @variations 3,4,5,8,9,10,11,12
// @sweeps arms, swirl, drag
// @still 24.0
// @teaches Compute shaders and GPU buffers
// @category Compute
// @tags compute shaders, particles, GPU buffers, steering, trails, hash, procedural sound
// @final Particle swarm | Sparks burn out and are born again beside the leader, so the swarm streams embers behind it.
//
// Two hundred thousand sparks chase a point that wanders around the picture. None of them is drawn by a fragment
// shader: each one is a few numbers in a buffer on the GPU, and a compute kernel moves every one of them sixty times a
// second. The host draws each spark as a dot, the dots pile up into an image, and the image fades a little each step,
// so the swarm leaves trails.
// Each @param line below becomes a slider in the host app and a row in the page's parameters table:
// // @param name default min max description
//
// @particles 200000
// @param speed 0.5 0.0 2.0 How fast the leader wanders
// @param pull 1.0 0.4 3.0 How hard each spark is pulled toward its place in the swarm
// @param arms 3.0 1.0 6.0 How many spiral arms each half of the swarm forms
// @param swirl 0.6 0.0 2.0 How fast the arms turn around the leader
// @param drag 3.0 1.0 6.0 How quickly the sparks lose speed: loose and wild, or tight and calm
// @param life 5.0 2.0 10.0 How many seconds a spark burns before it is born again beside the leader
// @param trails 0.85 0.5 0.95 How much of the last image is left at each step: short sparks or long streaks
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
// @param volume 0.8 0.0 1.0 Loudness of the sound
constant float TAU = 6.2831853;
// What one spark is: its place and speed (in a space one unit tall from the middle, as wide as the picture),
// how much of its life is left (1 to 0), and a random number of its own.
struct Particle {
float2 position;
float2 velocity;
float life;
float seed;
};
// Episode 5's integer hash: a whole number in, a random number from 0 to 1 out.
float hashU(uint n) {
n = (n << 13u) ^ n;
n = n * (n * n * 15731u + 789221u) + 1376312589u;
return float(n & 0x7fffffffu) / float(0x7fffffff);
}
// The leader: a slow loop of sines, never quite the same twice, across most of the picture.
float2 leader(float t, float aspect) {
return float2(aspect * (0.30 * sin(0.53 * t) + 0.10 * sin(1.31 * t + 1.0)),
0.24 * sin(0.71 * t + 0.6) + 0.08 * sin(1.67 * t + 2.0));
}
// Where the leader is at a step: the clock scaled by speed. At speed 0 it stays put.
float2 leaderAt(float time, float aspect, constant Params& p) {
return leader(time * p.speed + 3.0, aspect);
}
// Each spark is made once, at step 0: a random place in a disc, still, partway through its life, so they don't all
// burn out at the same moment.
Particle spawn(uint id, constant Params& p) {
Particle q;
float a = TAU * hashU(id * 4u);
float r = 0.9 * sqrt(hashU(id * 4u + 1u));
q.position = float2(cos(a), sin(a)) * r;
q.velocity = float2(0.0);
q.life = hashU(id * 4u + 2u);
q.seed = hashU(id * 4u + 3u);
return q;
}
// One step, for every spark at once: the compute kernel runs this for each of them, sixty times a second.
void update(thread Particle& q, uint id,
constant Uniforms& u, constant Params& p) {
float dt = u.timeDelta;
float aspect = u.resolution.x / u.resolution.y;
float2 target = leaderAt(u.time, aspect, p);
// Each spark has a place of its own around the leader, on one of the spiral arms: a radius from its id, an
// arm, and a little scatter. Half the sparks make arms that turn one way and half the other, crossing each other.
float spin = q.seed < 0.5 ? 1.0 : -1.0;
float radius = 0.06 + 0.7 * pow(hashU(id * 9u + 5u), 1.2);
float arms = max(floor(p.arms + 0.5), 1.0);
float arm = floor(hashU(id * 9u + 6u) * arms) * TAU / arms;
float scatter = (hashU(id * 9u + 7u) - 0.5) * 0.18;
float angle = arm + scatter + spin * (radius * 3.0 + u.time * p.swirl);
float2 goal = target + radius * float2(cos(angle), sin(angle));
// Pulled toward its place like a spring, harder the farther it is.
float2 force = (goal - q.position) * p.pull * 6.0;
// A little shake of its own, new at every step, so no two sparks follow the same path.
uint k = id * 2u + u.frame * 7919u;
force += (float2(hashU(k), hashU(k + 1u)) - 0.5) * 0.8;
q.velocity += force * dt;
q.velocity *= exp(-p.drag * dt);
q.position += q.velocity * dt;
// Burning down. A spark that burns out is born again beside the leader, thrown outward.
q.life -= dt / (p.life * (0.6 + 0.8 * q.seed));
if (q.life <= 0.0) {
float a = TAU * hashU(k + 2u);
float2 out = float2(cos(a), sin(a));
q.position = target + out * 0.04 * hashU(k + 3u);
q.velocity = out * (0.3 + 0.9 * hashU(k + 4u));
q.life = 1.0;
}
}
// Where to draw a spark (the picture's uv) and how big: a young spark is a little larger.
float3 place(Particle q, constant Uniforms& u, constant Params& p) {
float aspect = u.resolution.x / u.resolution.y;
float2 uv = q.position / float2(aspect, 1.0) * 0.5 + 0.5;
return float3(uv, 0.0025 + 0.002 * q.life);
}
// The kiln palette from episode 1.
float3 kiln(float t) {
float3 c0 = float3(0.020, 0.012, 0.020);
float3 c1 = float3(0.280, 0.040, 0.030);
float3 c2 = float3(0.880, 0.260, 0.050);
float3 c3 = float3(1.000, 0.680, 0.200);
float3 c4 = float3(1.000, 0.970, 0.840);
t = clamp(t, 0.0, 1.0);
float3 c = mix(c0, c1, smoothstep(0.00, 0.22, t));
c = mix(c, c2, smoothstep(0.22, 0.50, t));
c = mix(c, c3, smoothstep(0.50, 0.78, t));
c = mix(c, c4, smoothstep(0.78, 1.00, t));
return c;
}
// A spark's colour: hot while it is young and fast, cooling to a dull ember as it burns down. Soft at the edge.
// Longer trails keep more of every step, so each dot is dimmer by as much: the trails change length, not brightness.
float4 ink(Particle q, float2 at, constant Uniforms& u, constant Params& p) {
float fast = min(length(q.velocity) * 0.8, 1.0);
float t = 0.25 + 0.45 * q.life + 0.3 * fast + (p.heat - 0.5) * 0.4;
float edge = smoothstep(1.0, 0.0, length(at));
float keep = (1.0 - p.trails) / 0.15;
return float4(kiln(t) * edge * 0.09 * keep * (0.3 + q.life), 1.0);
}
// What is left of the last step's image: most of it, so every spark leaves a streak behind it.
float4 fade(float2 uv, Trails previous,
constant Uniforms& u, constant Params& p) {
return previous.at(uv) * p.trails;
}
// The picture: the sparks' image, its brightness rolled off so the thick of the swarm glows instead of clipping,
// over a dark warm ground.
float4 shade(float2 uv, constant Uniforms& u, constant Params& p,
Trails trails) {
float3 c = trails.at(uv).rgb;
c = c / (1.0 + c);
float2 q = uv - 0.5;
float3 ground = float3(0.030, 0.018, 0.020) * (1.0 - dot(q, q) * 1.6);
return float4(ground + c * 1.15, 1.0);
}
// A smooth random value from 0 to 1 that changes `rate` times a second: two hashed values eased into each other.
float noise1(float t, float rate, uint salt) {
float x = t * rate;
float i = floor(x);
float f = x - i;
f = f * f * (3.0 - 2.0 * f);
uint n = uint(int(i) + 1000000);
return mix(hashU(n * 31u + salt), hashU((n + 1u) * 31u + salt), f);
}
// The sound follows the leader, the one thing in the swarm that is a function of time alone (the sound has no state,
// so it can't read the sparks): a buzz of many detuned voices, like a swarm of bees, rising as the leader speeds up
// and panned to where it is; a crackle of sparks being born, thicker with shorter lives; and a low drone.
float2 sound(float t, constant Params& p) {
float aspect = 16.0 / 9.0;
float now = t * p.speed + 3.0;
float2 here = leader(now, aspect);
float2 ahead = leader(now + 0.05, aspect);
float pace = clamp(length(ahead - here) / 0.05 * p.speed, 0.0, 2.0);
float pan = clamp(0.5 + 0.35 * here.x / aspect, 0.1, 0.9);
// The buzz: eight voices around D3 (146.8 Hz), each a few cents apart, a little higher as the leader speeds up.
// Tighter drag and more swirl make a brighter buzz.
float base = 146.83 * (1.0 + 0.12 * pace);
float bright = clamp(0.3 + 0.2 * p.swirl + 0.1 * p.drag, 0.2, 1.0);
float buzz = 0.0;
for (int i = 0; i < 8; i++) {
float detune = 1.0 + (hashU(uint(i) * 5u + 11u) - 0.5) * 0.024;
float phase = hashU(uint(i) * 5u + 12u);
float x = fract(base * detune * t + phase);
// A soft sawtooth: a few of its harmonics.
float v = sin(TAU * x) + 0.5 * bright * sin(2.0 * TAU * x)
+ 0.33 * bright * bright * sin(3.0 * TAU * x);
buzz += v;
}
buzz *= 0.03 * (0.5 + 0.5 * pace) * (0.6 + 0.4 * noise1(t, 3.0, 5u));
// The crackle: short clicks of noise at random moments, more of them the shorter a spark's life.
float rate = 18.0 / p.life;
float slot = floor(t * rate);
float into = t * rate - slot;
uint s = uint(int(slot) + 1000000);
float when = hashU(s * 3u);
float age = (into - when) / rate;
float pop = age >= 0.0
? exp(-age * 300.0) * (1.0 - exp(-age * 20000.0)) : 0.0;
float grit = hashU(uint(t * 24000.0) + 77u) - 0.5;
float side = hashU(s * 3u + 1u);
float2 crackle = float2(1.0 - side, side) * pop * grit * 0.5;
// The drone: D2 and A2, slowly breathing.
float drone = sin(TAU * 73.42 * t) + 0.4 * sin(TAU * 110.0 * t + 1.0);
drone *= 0.05 * (0.8 + 0.2 * sin(0.4 * t));
float2 v = float2(1.0 - pan, pan) * 4.4 * buzz
+ crackle * 1.4 + float2(drone) * 3.0;
// tanh rounds off the loudest moments (every slider at its top) instead of letting them clip.
float fadeIn = smoothstep(0.0, 2.0, t);
return tanh(v * 1.2) / 1.2 * fadeIn * p.volume;
}Running it in your own project
Every Ray Kiln shader is the same shape. The standalone download above is the shader with the small wrapper around it that the Ray Kiln host adds, so it compiles with the ordinary Metal compiler and runs in your own app. It defines three entry points: rk_vertex (a full-screen triangle), rk_fragment (calls the shader's shade) and, for shaders with sound, the compute kernel rk_sound.
To draw it, pass the uniforms at fragment buffer 0 and the parameter values at buffer 1, in the order they are declared:
Drawing it, in Swift
struct Uniforms {
var resolution: SIMD2<Float>
var time: Float
var timeDelta: Float
var mouse: SIMD2<Float>
var frame: UInt32
var pad: UInt32 = 0
}
let library = try device.makeLibrary(source: standaloneSource, options: nil)
let descriptor = MTLRenderPipelineDescriptor()
descriptor.vertexFunction = library.makeFunction(name: "rk_vertex")
descriptor.fragmentFunction = library.makeFunction(name: "rk_fragment")
descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
let pipeline = try device.makeRenderPipelineState(descriptor: descriptor)
// Each frame, inside a render pass:
var uniforms = Uniforms(resolution: size, time: time, timeDelta: dt, mouse: mouse, frame: frame)
var params: [Float] = [0.5, 1.0, 3.0, 0.6, 3.0, 5.0, 0.85, 0.5, 0.8] // the @param defaults, in the order they are declared
encoder.setRenderPipelineState(pipeline)
encoder.setFragmentBytes(&uniforms, length: MemoryLayout<Uniforms>.stride, index: 0)
encoder.setFragmentBytes(¶ms, length: params.count * MemoryLayout<Float>.stride, index: 1)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)Time is in seconds, mouse is in pixels from the bottom left, and uv in the shader runs 0 to 1 with (0, 0) at the bottom left. Write the output as sRGB: the shader's numbers go to the screen as they are.
Press ? for keyboard shortcuts.