A Shader That Keeps the Beat
A drum machine drawn as a clock. Four rings of sixteen cells, one ring for each instrument, and a hand that sweeps round once a bar; where it passes a lit cell, that drum plays and the cell flashes. The sound and the picture are never told about each other. Both work out the same pattern and the same clock from the time, so they can't drift apart.
· Shapes & Symmetry · Fragment shader
Teaches A sequencer in the sound, the picture synced to it
Builds on 04 · Kaleidoscope
Watch
The idea
Every episode so far has had a sound, but the sound was a texture: drones, bells, crackle. Music needs a beat, and a beat needs a sequencer: a clock that counts steps, and a pattern that says on which steps each instrument plays. A drum machine is exactly that, sixteen steps to a bar, four to a beat.
A shader's sound(t, p) has no memory. It can't keep a list of notes it has started; every sample is worked out from the time alone. So the sequencer is a function too: from t, which step is playing, and from the step, whether the kick, the snare, the hat or the bell hits there and how long ago. shade calls the same functions with u.time. That is the whole trick of keeping a picture in sync with its music: don't send one to the other, let both compute the same thing.
The picture is the sequencer itself, drawn the way a drum machine's lights would be if it were round: a ring for each instrument, a cell for each step, and a hand going round once a bar. It is built in four steps, and each step is a shader you can run and hear on its own.
Stage 1 · A clock in steps
stepLength is the clock: a beat is 60 / tempo seconds and a step a quarter of that, so 15 / tempo, about 0.134 seconds at 112 beats a minute. Dividing the time by it gives the number of steps since the start: its whole part is the step playing, floor(t / stepLength), and % 16 is where that step is in its bar.
The bar is a ring of sixteen cells. A pixel's place on it comes from polar coordinates, as in episode 4: atan2(q.x, q.y) measures the angle clockwise from the top, so step 0 is at twelve o'clock, and turn * 16 says which cell the pixel is in. Each cell is a box in polar terms, its distance measured across the ring (dr) and along it (da, the angle times the radius, so the gaps stay even). The beats, every fourth step, are warmer, and the step playing is hot.
The sound ticks on every step, a short sine that starts soft so it doesn't click, at 1760 Hz on a beat and 880 between. You see the count and hear it.
stage-1-clock.metal
// Stage 1 · A clock in steps
// A sequencer counts time in steps: four to a beat, sixteen to a bar. Draw the bar as a ring of sixteen cells, with
// a hand that goes round once a bar, and light the cell whose step is playing. The sound ticks on every step, higher
// on each beat, so you hear the same count you see.
// @param tempo 112 70 160 Beats a minute: the clock both the picture and the sound keep
// @param volume 0.8 0.0 1.0 Loudness of the sound
constant float TAU = 6.2831853;
// 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.76, t));
return mix(c, c4, smoothstep(0.76, 1.00, t));
}
// Seconds a step lasts: a beat is 60 / tempo seconds, and a step a quarter of that.
float stepLength(constant Params& p) {
return 15.0 / max(p.tempo, 1.0);
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float px = 2.7 / min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution * px;
float r = length(q);
// The angle clockwise from the top, as a share of the circle: step 0 at twelve o'clock.
float turn = fract(atan2(q.x, q.y) / TAU);
int s = min(int(turn * 16.0), 15);
float inCell = turn * 16.0 - float(s);
// Step n since the start, and where the hand is in the bar.
float steps = u.time / stepLength(p);
int now = int(floor(steps));
float hand = fract(steps / 16.0) * 16.0;
// A cell: a slice of a ring, its distance found across the ring and along it.
float dr = abs(r - 0.8) - 0.12;
float da = (abs(inCell - 0.5) - 0.38) * (TAU / 16.0) * r;
float d = length(max(float2(dr, da), 0.0)) + min(max(dr, da), 0.0) - 0.015;
float inside = 1.0 - smoothstep(-px, px, d);
// The beats (every fourth step) warmer, and the playing step hot.
float heat = 0.04 + inside * (s % 4 == 0 ? 0.3 : 0.15);
if (s == now % 16) heat += inside * 0.6;
float off = fract((turn * 16.0 - hand) / 16.0 + 0.5) - 0.5;
float line = off * (TAU / 16.0) * r;
float onHand = 1.0 - smoothstep(0.0, 2.5 * px, abs(line));
heat += 0.45 * onHand * step(0.3, r) * step(r, 1.0);
return float4(kiln(heat), 1.0);
}
// The tick: a short high sine, starting soft so it doesn't click.
float2 sound(float t, constant Params& p) {
float steps = t / stepLength(p);
int now = int(floor(steps));
float since = fract(steps) * stepLength(p);
float pitch = (now % 4 == 0) ? 1760.0 : 880.0;
float env = smoothstep(0.0, 0.002, since) * exp(-since * 40.0);
float tick = sin(TAU * pitch * since) * env * 0.6;
return float2(tick) * smoothstep(0.0, 2.0, t) * p.volume;
}Stage 2 · Four tracks and a pattern
Four rings now, from the inside out: kick, snare, hat and bell (ringRadius). hit(track, n) is the pattern: the kick on steps 0, 8 and 11, the snare on 4 and 12, the hat on every even step, the bell on 2, 7, 10 and 15. A cell in the pattern is lit, and lit cells on the playing step are hot.
The sound asks the same hit. It can't remember that a kick started, so it looks back: for each of the last eight steps, if an instrument hit there, it is still ringing, and the seconds since that step began (since) say where in its sound it is. The voices are small:
- The kick is a sine that falls from 160 Hz to 48 Hz. The phase of a falling pitch is its integral,
48 * since + 112 * (1 - exp(-30 * since)) / 30, so it falls smoothly instead of chirping. - The snare is noise and a 185 Hz tone. The noise is a hash of the sample's number (
uint(t * 48000)), whole numbers throughhashBits, so it is the same on every GPU, in the app and in the browser. - The hat is the noise's change from one sample to the next,
n0 - n1, which keeps only its highs, a little to the right. - The bell is a sine on E.
stage-2-pattern.metal58 new lines since stage 1
// Stage 2 · Four tracks and a pattern
// Four rings, one for each instrument: the kick inside, then the snare, the hat and a bell. A pattern says on which
// steps each one hits, and those cells are lit. The sound reads the same pattern: whatever hit in the last eight
// steps still rings.
// @param tempo 112 70 160 Beats a minute: the clock both the picture and the sound keep
// @param volume 0.8 0.0 1.0 Loudness of the sound
constant float TAU = 6.2831853;
// 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.76, t));
return mix(c, c4, smoothstep(0.76, 1.00, t));
}
float stepLength(constant Params& p) {
return 15.0 / max(p.tempo, 1.0);
}
// The pattern: does track hit on step n? 0 kick, 1 snare, 2 hat, 3 bell.
bool hit(int track, int n) {
int s = n % 16;
if (track == 0) return s == 0 || s == 8 || s == 11;
if (track == 1) return s == 4 || s == 12;
if (track == 2) return s % 2 == 0;
return s == 2 || s == 7 || s == 10 || s == 15;
}
// The radius of each track's ring: the kick inside, the bell outside.
float ringRadius(int track) {
return 0.42 + 0.2 * float(track);
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float px = 2.7 / min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution * px;
float r = length(q);
float turn = fract(atan2(q.x, q.y) / TAU);
int s = min(int(turn * 16.0), 15);
float inCell = turn * 16.0 - float(s);
float steps = u.time / stepLength(p);
int now = int(floor(steps));
float hand = fract(steps / 16.0) * 16.0;
float heat = 0.04;
for (int k = 0; k < 4; k++) {
float dr = abs(r - ringRadius(k)) - 0.065;
float da = (abs(inCell - 0.5) - 0.38) * (TAU / 16.0) * r;
float2 e = float2(dr, da);
float d = length(max(e, 0.0)) + min(max(dr, da), 0.0) - 0.012;
float inside = 1.0 - smoothstep(-px, px, d);
bool lit = hit(k, s);
// In the pattern, warm; out of it, a dim slot. The playing step's lit cells are hot.
heat += inside * (lit ? 0.36 : 0.08);
if (lit && s == now % 16) heat += inside * 0.5;
}
float off = fract((turn * 16.0 - hand) / 16.0 + 0.5) - 0.5;
float line = off * (TAU / 16.0) * r;
float onHand = 1.0 - smoothstep(0.0, 2.5 * px, abs(line));
heat += 0.45 * onHand * step(0.3, r) * step(r, 1.16);
return float4(kiln(heat), 1.0);
}
// ---- The voices --------------------------------------------------------------------------------------------------
// A whole-number hash, the same on every GPU: for noise, by the sample's number.
uint hashBits(uint x) {
x ^= x >> 16;
x *= 0x7feb352du;
x ^= x >> 15;
x *= 0x846ca68bu;
x ^= x >> 16;
return x;
}
float noise(float t) {
return float(hashBits(uint(t * 48000.0))) / 2147483648.0 - 1.0;
}
// The kick: a sine falling from 160 Hz to 48 Hz; its phase is the integral of that frequency.
float kickVoice(float since) {
float phase = 48.0 * since + 112.0 * (1.0 - exp(-since * 30.0)) / 30.0;
return sin(TAU * phase);
}
float2 sound(float t, constant Params& p) {
float steps = t / stepLength(p);
int now = int(floor(steps));
float n0 = noise(t), n1 = noise(t - 1.0 / 48000.0);
float2 out = float2(0.0);
for (int back = 0; back < 8; back++) {
int n = now - back;
if (n < 0) break;
float since = t - float(n) * stepLength(p);
float soft = smoothstep(0.0, 0.002, since);
if (hit(0, n)) {
out += 0.55 * kickVoice(since) * soft * exp(-since * 9.0);
}
if (hit(1, n)) {
float body = 0.3 * n0 + 0.25 * sin(TAU * 185.0 * since);
out += body * soft * exp(-since * 18.0);
}
// The hat: the noise's change from one sample to the next, which keeps
// only its highs.
if (hit(2, n)) {
float hiss = 0.2 * (n0 - n1) * soft * exp(-since * 50.0);
out += float2(0.35, 0.65) * hiss;
}
if (hit(3, n)) {
out += 0.16 * sin(TAU * 659.25 * since) * soft * exp(-since * 4.0);
}
}
return tanh(out * 0.85) * smoothstep(0.0, 2.0, t) * p.volume;
}10 lines from stage 1 that are gone
float dr = abs(r - 0.8) - 0.12;
⋯
float d = length(max(float2(dr, da), 0.0)) + min(max(dr, da), 0.0) - 0.015;
⋯
float heat = 0.04 + inside * (s % 4 == 0 ? 0.3 : 0.15);
if (s == now % 16) heat += inside * 0.6;
⋯
heat += 0.45 * onHand * step(0.3, r) * step(r, 1.0);
⋯
float since = fract(steps) * stepLength(p);
float pitch = (now % 4 == 0) ? 1760.0 : 880.0;
float env = smoothstep(0.0, 0.002, since) * exp(-since * 40.0);
float tick = sin(TAU * pitch * since) * env * 0.6;
return float2(tick) * smoothstep(0.0, 2.0, t) * p.volume;Stage 3 · One envelope for the ear and the eye
A hit rises and dies away, and the shape of that is its envelope: envelope(since, rate) is smoothstep(0, 0.002, since), two milliseconds up so there's no click, times exp(-since * rate) down. Each track fades at its own rate (fadeRate: the hat fastest, the bell slowest), all slower as decay goes up.
The picture uses the very same function. For each cell, the step's most recent start is found (this bar's if the hand has passed it, else the last bar's), and if that step hit, the cell flashes by its envelope, at 0.6 of the sound's rate so the eye can follow it. sinceHit(track, t) looks back for a track's last hit, as the sound does, and the heart at the middle swells and whitens with the kick's envelope. Turn decay up and both the drums and the light ring longer, together.
stage-3-envelope.metal38 new lines since stage 2
// Stage 3 · One envelope for the ear and the eye
// A hit rises in two milliseconds and dies away: its envelope. The picture uses the very one the sound does, so each
// cell flashes when its step hits and fades as its sound fades, and the heart beats with the kick. More decay, and
// both ring longer.
// @param tempo 112 70 160 Beats a minute: the clock both the picture and the sound keep
// @param decay 0.5 0.0 1.0 How long each hit rings in the sound and glows in the picture
// @param volume 0.8 0.0 1.0 Loudness of the sound
constant float TAU = 6.2831853;
// 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.76, t));
return mix(c, c4, smoothstep(0.76, 1.00, t));
}
float stepLength(constant Params& p) {
return 15.0 / max(p.tempo, 1.0);
}
// The pattern: does track hit on step n? 0 kick, 1 snare, 2 hat, 3 bell.
bool hit(int track, int n) {
int s = n % 16;
if (track == 0) return s == 0 || s == 8 || s == 11;
if (track == 1) return s == 4 || s == 12;
if (track == 2) return s % 2 == 0;
return s == 2 || s == 7 || s == 10 || s == 15;
}
// How a hit fades: up in 2 ms (no click), then down at `rate`. The sound is shaped by it, and the picture glows by it.
float envelope(float since, float rate) {
return smoothstep(0.0, 0.002, since) * exp(-since * rate);
}
// How fast each track fades, slower with more decay.
float fadeRate(int track, constant Params& p) {
float slow = mix(2.0, 0.5, p.decay);
float rates[4] = {7.0, 14.0, 40.0, 3.0};
return rates[track] * slow;
}
// Seconds since track last hit, looking back up to twelve steps; a large number when it hasn't.
float sinceHit(int track, float t, constant Params& p) {
int now = int(floor(t / stepLength(p)));
for (int back = 0; back < 12; back++) {
int n = now - back;
if (n < 0) break;
if (hit(track, n)) return t - float(n) * stepLength(p);
}
return 100.0;
}
// The radius of each track's ring: the kick inside, the bell outside.
float ringRadius(int track) {
return 0.42 + 0.2 * float(track);
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float px = 2.7 / min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution * px;
float r = length(q);
float turn = fract(atan2(q.x, q.y) / TAU);
int s = min(int(turn * 16.0), 15);
float inCell = turn * 16.0 - float(s);
float steps = u.time / stepLength(p);
int now = int(floor(steps));
float hand = fract(steps / 16.0) * 16.0;
float heat = 0.04;
for (int k = 0; k < 4; k++) {
float dr = abs(r - ringRadius(k)) - 0.065;
float da = (abs(inCell - 0.5) - 0.38) * (TAU / 16.0) * r;
float2 e = float2(dr, da);
float d = length(max(e, 0.0)) + min(max(dr, da), 0.0) - 0.012;
float inside = 1.0 - smoothstep(-px, px, d);
// The step's most recent start: this bar's if it has come, else the last bar's. The cell flashes by the
// envelope of that hit, a little slower than the sound so the eye can follow it.
int n = (now / 16) * 16 + s;
if (n > now) n -= 16;
float flash = 0.0;
if (n >= 0 && hit(k, n)) {
float since = u.time - float(n) * stepLength(p);
flash = envelope(since, fadeRate(k, p) * 0.6);
}
heat += inside * ((hit(k, s) ? 0.36 : 0.08) + 0.6 * flash);
}
// The heart: a disc at the middle that beats with the kick.
float kick = envelope(sinceHit(0, u.time, p), fadeRate(0, p));
float heart = r - (0.2 + 0.05 * kick);
heat += (1.0 - smoothstep(-px, px, heart)) * (0.3 + 0.6 * kick);
float off = fract((turn * 16.0 - hand) / 16.0 + 0.5) - 0.5;
float line = off * (TAU / 16.0) * r;
float onHand = 1.0 - smoothstep(0.0, 2.5 * px, abs(line));
heat += 0.45 * onHand * step(0.3, r) * step(r, 1.16);
return float4(kiln(heat), 1.0);
}
// ---- The voices --------------------------------------------------------------------------------------------------
// A whole-number hash, the same on every GPU: for noise, by the sample's number.
uint hashBits(uint x) {
x ^= x >> 16;
x *= 0x7feb352du;
x ^= x >> 15;
x *= 0x846ca68bu;
x ^= x >> 16;
return x;
}
float noise(float t) {
return float(hashBits(uint(t * 48000.0))) / 2147483648.0 - 1.0;
}
// The kick: a sine falling from 160 Hz to 48 Hz; its phase is the integral of that frequency.
float kickVoice(float since) {
float phase = 48.0 * since + 112.0 * (1.0 - exp(-since * 30.0)) / 30.0;
return sin(TAU * phase);
}
float2 sound(float t, constant Params& p) {
float steps = t / stepLength(p);
int now = int(floor(steps));
float n0 = noise(t), n1 = noise(t - 1.0 / 48000.0);
float2 out = float2(0.0);
for (int back = 0; back < 12; back++) {
int n = now - back;
if (n < 0) break;
float since = t - float(n) * stepLength(p);
// A hit twelve steps back is let go, so it fades out over the last three instead of stopping.
float held = 1.0 - smoothstep(9.0, 12.0, since / stepLength(p));
if (hit(0, n)) {
float e = envelope(since, fadeRate(0, p));
out += 0.55 * held * kickVoice(since) * e;
}
if (hit(1, n)) {
float tone = 0.25 * sin(TAU * 185.0 * since) * exp(-since * 30.0);
out += held * (0.3 * n0 + tone) * envelope(since, fadeRate(1, p));
}
if (hit(2, n)) {
float e = envelope(since, fadeRate(2, p));
out += float2(0.35, 0.65) * 0.2 * held * (n0 - n1) * e;
}
if (hit(3, n)) {
float e = envelope(since, fadeRate(3, p));
out += 0.16 * held * sin(TAU * 659.25 * since) * e;
}
}
return tanh(out * 0.85) * smoothstep(0.0, 2.0, t) * p.volume;
}11 lines from stage 2 that are gone
bool lit = hit(k, s);
⋯
heat += inside * (lit ? 0.36 : 0.08);
if (lit && s == now % 16) heat += inside * 0.5;
⋯
for (int back = 0; back < 8; back++) {
⋯
float soft = smoothstep(0.0, 0.002, since);
⋯
out += 0.55 * kickVoice(since) * soft * exp(-since * 9.0);
⋯
float body = 0.3 * n0 + 0.25 * sin(TAU * 185.0 * since);
out += body * soft * exp(-since * 18.0);
⋯
float hiss = 0.2 * (n0 - n1) * soft * exp(-since * 50.0);
out += float2(0.35, 0.65) * hiss;
⋯
out += 0.16 * sin(TAU * 659.25 * since) * soft * exp(-since * 4.0);Stage 4 · Swing and dice
A fixed pattern gets dull, so the pattern rolls dice. dice(track, n) hashes the track, the step, the bar within a phrase of four and the pattern number into a number from 0 to 1: the same cell in the same bar of the same pattern always rolls the same, so the loop repeats as music does, and a different pattern is a different groove. The fixed hits stay (the kick on 0 and 8, the snare on 4 and 12, the hat on the even steps), and density decides how many extras get in: kicks on 3, 6, 11 and 14, ghost snares, hats between, bell notes. The last bar of each phrase may end in a fill of snares on steps 13 to 15.
Swing makes every odd step late by a share of a step (stepStart), the lilt of most dance music. Once steps aren't evenly spaced, the step playing isn't just floor(t / stepLength) any more: stepAt takes it and steps back one if that step hasn't started yet. The hand moves from each step's start to the next, so with swing it waits and hurries exactly as the sound does. The bell plays notes now, from A minor pentatonic, A4 up to D6, each note and its place between the ears picked by the hash of its step. A hit twelve steps back is let go over the last three (held), so nothing stops with a click.
stage-4-swing.metal91 new lines since stage 3
// Stage 4 · Swing and dice
// Roll dice for every cell, the same for the same pattern and bar; density lets more extra hits in. The dice change
// over a phrase of four bars, which may end in a fill. Swing makes every other step late, and the bell plays notes
// the dice pick.
// @param tempo 112 70 160 Beats a minute: the clock both the picture and the sound keep
// @param swing 0.15 0.0 0.5 How late every other sixteenth comes, as a share of a step
// @param density 0.5 0.0 1.0 How many extra hits fill the pattern around the fixed ones
// @param pattern 3 0 9 Which pattern: every whole number is a different one
// @param decay 0.5 0.0 1.0 How long each hit rings in the sound and glows in the picture
// @param volume 0.8 0.0 1.0 Loudness of the sound
constant float TAU = 6.2831853;
// ---- The sequencer -------------------------------------------------------------------------------------------------
// Sixteen steps to a bar, four to a beat. Step n is counted from the start, so n / 16 is the bar and n % 16 the step
// in it. The picture and the sound both call these, with the same time, so they can't drift apart.
// Seconds a step lasts: a beat is 60 / tempo seconds, and a step a quarter of that.
float stepLength(constant Params& p) {
return 15.0 / max(p.tempo, 1.0);
}
// When step n starts. Swing: every odd step comes late, by a share of a step.
float stepStart(int n, constant Params& p) {
float late = (n % 2 == 1) ? p.swing : 0.0;
return (float(n) + late) * stepLength(p);
}
// The step playing at time t: the last one that has started.
int stepAt(float t, constant Params& p) {
int n = int(floor(t / stepLength(p)));
if (n > 0 && stepStart(n, p) > t) n -= 1;
return n;
}
// A whole-number hash, the same on every GPU: used for the pattern's dice and for noise.
uint hashBits(uint x) {
x ^= x >> 16;
x *= 0x7feb352du;
x ^= x >> 15;
x *= 0x846ca68bu;
x ^= x >> 16;
return x;
}
// Dice from 0 to 1 for one cell: the same for the same pattern, bar, track and step.
float dice(int track, int n, constant Params& p) {
uint bar = uint(n / 16) % 4u;
uint seed = uint(max(p.pattern, 0.0));
uint key = uint(track * 16 + n % 16) + bar * 64u + seed * 256u;
return float(hashBits(key + 12345u) & 0xffffu) / 65535.0;
}
// Does track hit on step n? Fixed hits, and extras the dice add, more with density.
bool hit(int track, int n, constant Params& p) {
int s = n % 16;
float r = dice(track, n, p);
float d = p.density;
if (track == 0) {
bool extra = s == 3 || s == 6 || s == 11 || s == 14;
return s % 8 == 0 || (extra && r < d * 0.7);
}
if (track == 1) {
// The last bar of the phrase may end in a fill.
bool fill = (n / 16) % 4 == 3 && s >= 13 && r < d;
return s == 4 || s == 12 || fill || (s % 2 == 1 && r < d * 0.2);
}
if (track == 2) return s % 2 == 0 || r < d;
return r < 0.12 + d * 0.3;
}
// How a hit fades: up in 2 ms (no click), then down at `rate`. The sound is shaped by it, and the picture glows by it.
float envelope(float since, float rate) {
return smoothstep(0.0, 0.002, since) * exp(-since * rate);
}
// How fast each track fades, slower with more decay.
float fadeRate(int track, constant Params& p) {
float slow = mix(2.0, 0.5, p.decay);
float rates[4] = {7.0, 14.0, 40.0, 3.0};
return rates[track] * slow;
}
// Seconds since track last hit, looking back up to twelve steps; a large number when it hasn't.
float sinceHit(int track, float t, constant Params& p) {
int now = stepAt(t, p);
for (int back = 0; back < 12; back++) {
int n = now - back;
if (n < 0) break;
if (hit(track, n, p)) return t - stepStart(n, p);
}
return 100.0;
}
// ---- The clock face ------------------------------------------------------------------------------------------------
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.76, t));
return mix(c, c4, smoothstep(0.76, 1.00, t));
}
// The radius of each track's ring: the kick inside, the bell outside.
float ringRadius(int track) {
return 0.42 + 0.2 * float(track);
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float t = u.time;
float kick = envelope(sinceHit(0, t, p), fadeRate(0, p));
float px = 2.7 / min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution * px;
float r = length(q);
// The angle clockwise from the top, as a share of the circle: step 0 at twelve o'clock.
float turn = fract(atan2(q.x, q.y) / TAU);
int s = min(int(turn * 16.0), 15);
float inCell = turn * 16.0 - float(s);
// The hand: where the bar is now, in steps. With swing it waits and hurries
// as the sound does.
int now = stepAt(t, p);
float a = stepStart(now, p), b = stepStart(now + 1, p);
float hand = float(now % 16) + clamp((t - a) / max(b - a, 1e-4), 0.0, 1.0);
float heat = 0.04;
// The cells: dim, warm in this bar's pattern, flashing when hit.
int bar = now / 16;
for (int k = 0; k < 4; k++) {
float dr = abs(r - ringRadius(k)) - 0.065;
float da = (abs(inCell - 0.5) - 0.38) * (TAU / 16.0) * r;
float2 e = float2(dr, da);
float d = length(max(e, 0.0)) + min(max(dr, da), 0.0) - 0.012;
float inside = 1.0 - smoothstep(-px, px, d);
// The step's most recent start: this bar's if it has come, else the last bar's.
int n = bar * 16 + s;
if (n > now) n -= 16;
float flash = 0.0;
if (n >= 0 && hit(k, n, p)) {
flash = envelope(t - stepStart(n, p), fadeRate(k, p) * 0.6);
}
bool lit = hit(k, bar * 16 + s, p);
heat += inside * ((lit ? 0.36 : 0.08) + 0.6 * flash);
}
float off = fract((turn * 16.0 - hand) / 16.0 + 0.5) - 0.5;
float line = off * (TAU / 16.0) * r;
float onHand = 1.0 - smoothstep(0.0, 2.5 * px, abs(line));
heat += 0.45 * onHand * step(0.3, r) * step(r, 1.16);
// The heart: a disc at the middle that beats with the kick.
float heart = r - (0.2 + 0.05 * kick);
heat += (1.0 - smoothstep(-px, px, heart)) * (0.3 + 0.6 * kick);
float3 col = kiln(heat);
return float4(col, 1.0);
}
// ---- Sound -----------------------------------------------------------------------------------------------------
// The same sequencer, heard. For every track, the last twelve steps are looked at, and every hit among them still
// rings: its voice, from the seconds since it started, shaped by the same envelope that makes its cell glow.
// Noise, the same on every GPU: a hash of the sample's number, from -1 to 1.
float noise(float t) {
return float(hashBits(uint(t * 48000.0))) / 2147483648.0 - 1.0;
}
// The kick: a sine that falls from 160 Hz to 48 Hz. Its phase is the integral of that frequency, so it falls
// smoothly instead of chirping.
float kickVoice(float since) {
float phase = 48.0 * since + 112.0 * (1.0 - exp(-since * 30.0)) / 30.0;
return sin(TAU * phase);
}
// A bell: a sine bent by another at twice its pitch, the bending dying away fast so the strike is bright and the ring
// pure.
float bellVoice(float f, float since) {
float bend = 1.2 * exp(-since * 6.0) * sin(TAU * 2.0 * f * since);
return sin(TAU * f * since + bend);
}
// A minor pentatonic over two octaves, from A4.
float bellNote(int i) {
float notes[8] = {440.00, 523.25, 587.33, 659.25,
783.99, 880.00, 1046.50, 1174.66};
return notes[clamp(i, 0, 7)];
}
float2 sound(float t, constant Params& p) {
int now = stepAt(t, p);
float n0 = noise(t), n1 = noise(t - 1.0 / 48000.0);
float2 mix2 = float2(0.0);
for (int back = 0; back < 12; back++) {
int n = now - back;
if (n < 0) break;
// A hit twelve steps back is let go, so it fades out over the last three instead of stopping.
float since = t - stepStart(n, p);
float held = 1.0 - smoothstep(9.0, 12.0, since / stepLength(p));
if (hit(0, n, p)) {
float e = envelope(since, fadeRate(0, p));
mix2 += 0.55 * held * kickVoice(since) * e;
}
if (hit(1, n, p)) {
float tone = 0.25 * sin(TAU * 185.0 * since) * exp(-since * 30.0);
mix2 += held * (0.3 * n0 + tone) * envelope(since, fadeRate(1, p));
}
// The hat: the noise's change from one sample to the next, which keeps only its highs. A little to the right.
if (hit(2, n, p)) {
float e = envelope(since, fadeRate(2, p));
mix2 += float2(0.35, 0.65) * 0.2 * held * (n0 - n1) * e;
}
if (hit(3, n, p)) {
uint h = hashBits(uint(n) * 7u + uint(max(p.pattern, 0.0)) * 1013u);
float f = bellNote(int(h % 8u));
float pan = float((h >> 8) & 255u) / 255.0;
float e = envelope(since, fadeRate(3, p));
float ring = 0.16 * held * bellVoice(f, since) * e;
mix2 += float2(1.0 - pan * 0.6, 0.4 + pan * 0.6) * ring;
}
}
float fadeIn = smoothstep(0.0, 2.0, t);
return tanh(mix2 * 0.85) * fadeIn * p.volume;
}52 lines from stage 3 that are gone
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.76, t));
return mix(c, c4, smoothstep(0.76, 1.00, t));
}
⋯
bool hit(int track, int n) {
⋯
if (track == 0) return s == 0 || s == 8 || s == 11;
if (track == 1) return s == 4 || s == 12;
if (track == 2) return s % 2 == 0;
return s == 2 || s == 7 || s == 10 || s == 15;
⋯
int now = int(floor(t / stepLength(p)));
⋯
if (hit(track, n)) return t - float(n) * stepLength(p);
⋯
float steps = u.time / stepLength(p);
int now = int(floor(steps));
float hand = fract(steps / 16.0) * 16.0;
⋯
int n = (now / 16) * 16 + s;
⋯
if (n >= 0 && hit(k, n)) {
float since = u.time - float(n) * stepLength(p);
flash = envelope(since, fadeRate(k, p) * 0.6);
⋯
heat += inside * ((hit(k, s) ? 0.36 : 0.08) + 0.6 * flash);
⋯
float kick = envelope(sinceHit(0, u.time, p), fadeRate(0, p));
float heart = r - (0.2 + 0.05 * kick);
heat += (1.0 - smoothstep(-px, px, heart)) * (0.3 + 0.6 * kick);
⋯
return float4(kiln(heat), 1.0);
}
⋯
uint hashBits(uint x) {
x ^= x >> 16;
x *= 0x7feb352du;
x ^= x >> 15;
x *= 0x846ca68bu;
x ^= x >> 16;
return x;
⋯
float steps = t / stepLength(p);
int now = int(floor(steps));
⋯
float2 out = float2(0.0);
⋯
float since = t - float(n) * stepLength(p);
⋯
if (hit(0, n)) {
⋯
out += 0.55 * held * kickVoice(since) * e;
⋯
if (hit(1, n)) {
⋯
out += held * (0.3 * n0 + tone) * envelope(since, fadeRate(1, p));
⋯
if (hit(2, n)) {
⋯
out += float2(0.35, 0.65) * 0.2 * held * (n0 - n1) * e;
⋯
if (hit(3, n)) {
⋯
out += 0.16 * held * sin(TAU * 659.25 * since) * e;
⋯
return tanh(out * 0.85) * smoothstep(0.0, 2.0, t) * p.volume;Stage 5 · Keeping the beat
The last step is all picture: nothing new in the sequencer, just more ways to see it.
- The hand's trail. The steps behind the hand stay warm and fade,
exp(-behind * 1.4), so the bar's last second is visible at a glance. - The shock ring. Every snare sends a ring out from the heart, at
0.22 + since * 2.2, fading byexp(-since * 3). - The kick. The whole drum swells by 4% on each kick, the kiln's warmth at the middle brightens, and a soft wave runs out past the drum to the edges of the picture.
- The cells' edges and guide circles under each ring, and
heatto slide the kiln palette, as in every episode.
the kiln palette and shade(), from the finished shader
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.76, t));
return mix(c, c4, smoothstep(0.76, 1.00, t));
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float t = u.time;
float kickSince = sinceHit(0, t, p);
float kick = envelope(kickSince, fadeRate(0, p));
float snareSince = sinceHit(1, t, p);
// The whole drum swells a little with every kick.
float px = 2.7 / min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution * px / (1.0 + 0.04 * kick);
float r = length(q);
// The angle clockwise from the top, as a share of the circle: step 0 at twelve o'clock.
float turn = fract(atan2(q.x, q.y) / TAU);
int s = min(int(turn * 16.0), 15);
float inCell = turn * 16.0 - float(s);
// The hand: where the bar is now, in steps. With swing it waits and hurries
// as the sound does.
int now = stepAt(t, p);
float a = stepStart(now, p), b = stepStart(now + 1, p);
float hand = float(now % 16) + clamp((t - a) / max(b - a, 1e-4), 0.0, 1.0);
// The background: the dark of the kiln, warm at the middle, brighter on the kick.
float heat = 0.04 + (0.12 + 0.25 * kick) * exp(-r * 2.2);
// A thin guide circle under each ring.
for (int k = 0; k < 4; k++) {
heat += 0.05 * exp(-abs(r - ringRadius(k)) * 90.0);
}
// The cells: dim, warm in this bar's pattern, flashing when hit.
int bar = now / 16;
for (int k = 0; k < 4; k++) {
float dr = abs(r - ringRadius(k)) - 0.065;
float da = (abs(inCell - 0.5) - 0.38) * (TAU / 16.0) * r;
float2 e = float2(dr, da);
float d = length(max(e, 0.0)) + min(max(dr, da), 0.0) - 0.012;
float inside = 1.0 - smoothstep(-px, px, d);
float edge = 1.0 - smoothstep(0.0, 2.0 * px, abs(d));
// The step's most recent start: this bar's if it has come, else the last bar's.
int n = bar * 16 + s;
if (n > now) n -= 16;
float flash = 0.0;
if (n >= 0 && hit(k, n, p)) {
flash = envelope(t - stepStart(n, p), fadeRate(k, p) * 0.6);
}
bool lit = hit(k, bar * 16 + s, p);
heat += edge * 0.14;
heat += inside * ((lit ? 0.36 : 0.08) + 0.6 * flash);
}
// The hand's trail: bright at the hand and fading over the steps behind it.
float behind = fract((hand - turn * 16.0) / 16.0) * 16.0;
float band = smoothstep(0.3, 0.42, r) * (1.0 - smoothstep(1.1, 1.18, r));
heat += 0.18 * exp(-behind * 1.4) * band;
float off = fract((turn * 16.0 - hand) / 16.0 + 0.5) - 0.5;
float line = off * (TAU / 16.0) * r;
float onHand = 1.0 - smoothstep(0.0, 2.5 * px, abs(line));
heat += 0.45 * onHand * step(0.3, r) * step(r, 1.16);
// The heart: a disc at the middle that beats with the kick.
float heart = length(q) - (0.2 + 0.05 * kick);
heat += (1.0 - smoothstep(-px, px, heart)) * (0.3 + 0.6 * kick);
// Every snare sends a shock ring out from the heart.
float shock = abs(r - (0.22 + snareSince * 2.2)) - 0.01;
float onShock = 1.0 - smoothstep(0.0, 6.0 * px, shock);
heat += 0.35 * exp(-snareSince * 3.0) * onShock;
// And every kick sends a soft wave out past the drum, to the edges of the picture.
float wave = abs(r - (1.2 + kickSince * 2.5));
heat += 0.1 * exp(-kickSince * 2.0) * exp(-wave * 10.0);
float3 col = kiln(heat + (p.heat - 0.5) * 0.5);
return float4(col, 1.0);
}episode-13-a-shader-that-keeps-the-beat.metal, the whole file20 new lines since stage 4
// Ray Kiln · Episode 13 · A Shader That Keeps the Beat
//
// @episode 13 A Shader That Keeps the Beat
// @length 60
// @variation 30
// @variations 1,2,3,4,5,6,10,12
// @still 18.25
// @teaches A sequencer in the sound, the picture synced to it
// @category Fragment
// @tags sequencer, envelope, swing, polar coordinates, procedural sound
// @final Keeping the beat | The kiln's colours, the hand's trail, a shock ring on every snare and the whole drum breathing with the kick.
//
// A drum machine drawn as a clock: four rings of sixteen cells, one ring for each instrument, and a hand that sweeps
// round once a bar. Where the hand passes a lit cell, the sound plays that instrument and the cell flashes. The sound
// and the picture are never told about each other: both work out the same pattern and the same clock from the time.
// 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
// @param tempo 112 70 160 Beats a minute: the clock both the picture and the sound keep
// @param swing 0.15 0.0 0.5 How late every other sixteenth comes, as a share of a step
// @param density 0.5 0.0 1.0 How many extra hits fill the pattern around the fixed ones
// @param pattern 3 0 9 Which pattern: every whole number is a different one
// @param decay 0.5 0.0 1.0 How long each hit rings in the sound and glows in the picture
// @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;
// ---- The sequencer -------------------------------------------------------------------------------------------------
// Sixteen steps to a bar, four to a beat. Step n is counted from the start, so n / 16 is the bar and n % 16 the step
// in it. The picture and the sound both call these, with the same time, so they can't drift apart.
// Seconds a step lasts: a beat is 60 / tempo seconds, and a step a quarter of that.
float stepLength(constant Params& p) {
return 15.0 / max(p.tempo, 1.0);
}
// When step n starts. Swing: every odd step comes late, by a share of a step.
float stepStart(int n, constant Params& p) {
float late = (n % 2 == 1) ? p.swing : 0.0;
return (float(n) + late) * stepLength(p);
}
// The step playing at time t: the last one that has started.
int stepAt(float t, constant Params& p) {
int n = int(floor(t / stepLength(p)));
if (n > 0 && stepStart(n, p) > t) n -= 1;
return n;
}
// A whole-number hash, the same on every GPU: used for the pattern's dice and for noise.
uint hashBits(uint x) {
x ^= x >> 16;
x *= 0x7feb352du;
x ^= x >> 15;
x *= 0x846ca68bu;
x ^= x >> 16;
return x;
}
// Dice from 0 to 1 for one cell: the same for the same pattern, bar, track and step.
float dice(int track, int n, constant Params& p) {
uint bar = uint(n / 16) % 4u;
uint seed = uint(max(p.pattern, 0.0));
uint key = uint(track * 16 + n % 16) + bar * 64u + seed * 256u;
return float(hashBits(key + 12345u) & 0xffffu) / 65535.0;
}
// Does track hit on step n? Fixed hits, and extras the dice add, more with density.
bool hit(int track, int n, constant Params& p) {
int s = n % 16;
float r = dice(track, n, p);
float d = p.density;
if (track == 0) {
bool extra = s == 3 || s == 6 || s == 11 || s == 14;
return s % 8 == 0 || (extra && r < d * 0.7);
}
if (track == 1) {
// The last bar of the phrase may end in a fill.
bool fill = (n / 16) % 4 == 3 && s >= 13 && r < d;
return s == 4 || s == 12 || fill || (s % 2 == 1 && r < d * 0.2);
}
if (track == 2) return s % 2 == 0 || r < d;
return r < 0.12 + d * 0.3;
}
// How a hit fades: up in 2 ms (no click), then down at `rate`. The sound is shaped by it, and the picture glows by it.
float envelope(float since, float rate) {
return smoothstep(0.0, 0.002, since) * exp(-since * rate);
}
// How fast each track fades, slower with more decay.
float fadeRate(int track, constant Params& p) {
float slow = mix(2.0, 0.5, p.decay);
float rates[4] = {7.0, 14.0, 40.0, 3.0};
return rates[track] * slow;
}
// Seconds since track last hit, looking back up to twelve steps; a large number when it hasn't.
float sinceHit(int track, float t, constant Params& p) {
int now = stepAt(t, p);
for (int back = 0; back < 12; back++) {
int n = now - back;
if (n < 0) break;
if (hit(track, n, p)) return t - stepStart(n, p);
}
return 100.0;
}
// ---- The clock face ------------------------------------------------------------------------------------------------
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.76, t));
return mix(c, c4, smoothstep(0.76, 1.00, t));
}
// The radius of each track's ring: the kick inside, the bell outside.
float ringRadius(int track) {
return 0.42 + 0.2 * float(track);
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float t = u.time;
float kickSince = sinceHit(0, t, p);
float kick = envelope(kickSince, fadeRate(0, p));
float snareSince = sinceHit(1, t, p);
// The whole drum swells a little with every kick.
float px = 2.7 / min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution * px / (1.0 + 0.04 * kick);
float r = length(q);
// The angle clockwise from the top, as a share of the circle: step 0 at twelve o'clock.
float turn = fract(atan2(q.x, q.y) / TAU);
int s = min(int(turn * 16.0), 15);
float inCell = turn * 16.0 - float(s);
// The hand: where the bar is now, in steps. With swing it waits and hurries
// as the sound does.
int now = stepAt(t, p);
float a = stepStart(now, p), b = stepStart(now + 1, p);
float hand = float(now % 16) + clamp((t - a) / max(b - a, 1e-4), 0.0, 1.0);
// The background: the dark of the kiln, warm at the middle, brighter on the kick.
float heat = 0.04 + (0.12 + 0.25 * kick) * exp(-r * 2.2);
// A thin guide circle under each ring.
for (int k = 0; k < 4; k++) {
heat += 0.05 * exp(-abs(r - ringRadius(k)) * 90.0);
}
// The cells: dim, warm in this bar's pattern, flashing when hit.
int bar = now / 16;
for (int k = 0; k < 4; k++) {
float dr = abs(r - ringRadius(k)) - 0.065;
float da = (abs(inCell - 0.5) - 0.38) * (TAU / 16.0) * r;
float2 e = float2(dr, da);
float d = length(max(e, 0.0)) + min(max(dr, da), 0.0) - 0.012;
float inside = 1.0 - smoothstep(-px, px, d);
float edge = 1.0 - smoothstep(0.0, 2.0 * px, abs(d));
// The step's most recent start: this bar's if it has come, else the last bar's.
int n = bar * 16 + s;
if (n > now) n -= 16;
float flash = 0.0;
if (n >= 0 && hit(k, n, p)) {
flash = envelope(t - stepStart(n, p), fadeRate(k, p) * 0.6);
}
bool lit = hit(k, bar * 16 + s, p);
heat += edge * 0.14;
heat += inside * ((lit ? 0.36 : 0.08) + 0.6 * flash);
}
// The hand's trail: bright at the hand and fading over the steps behind it.
float behind = fract((hand - turn * 16.0) / 16.0) * 16.0;
float band = smoothstep(0.3, 0.42, r) * (1.0 - smoothstep(1.1, 1.18, r));
heat += 0.18 * exp(-behind * 1.4) * band;
float off = fract((turn * 16.0 - hand) / 16.0 + 0.5) - 0.5;
float line = off * (TAU / 16.0) * r;
float onHand = 1.0 - smoothstep(0.0, 2.5 * px, abs(line));
heat += 0.45 * onHand * step(0.3, r) * step(r, 1.16);
// The heart: a disc at the middle that beats with the kick.
float heart = length(q) - (0.2 + 0.05 * kick);
heat += (1.0 - smoothstep(-px, px, heart)) * (0.3 + 0.6 * kick);
// Every snare sends a shock ring out from the heart.
float shock = abs(r - (0.22 + snareSince * 2.2)) - 0.01;
float onShock = 1.0 - smoothstep(0.0, 6.0 * px, shock);
heat += 0.35 * exp(-snareSince * 3.0) * onShock;
// And every kick sends a soft wave out past the drum, to the edges of the picture.
float wave = abs(r - (1.2 + kickSince * 2.5));
heat += 0.1 * exp(-kickSince * 2.0) * exp(-wave * 10.0);
float3 col = kiln(heat + (p.heat - 0.5) * 0.5);
return float4(col, 1.0);
}
// ---- Sound -----------------------------------------------------------------------------------------------------
// The same sequencer, heard. For every track, the last twelve steps are looked at, and every hit among them still
// rings: its voice, from the seconds since it started, shaped by the same envelope that makes its cell glow.
// Noise, the same on every GPU: a hash of the sample's number, from -1 to 1.
float noise(float t) {
return float(hashBits(uint(t * 48000.0))) / 2147483648.0 - 1.0;
}
// The kick: a sine that falls from 160 Hz to 48 Hz. Its phase is the integral of that frequency, so it falls
// smoothly instead of chirping.
float kickVoice(float since) {
float phase = 48.0 * since + 112.0 * (1.0 - exp(-since * 30.0)) / 30.0;
return sin(TAU * phase);
}
// A bell: a sine bent by another at twice its pitch, the bending dying away fast so the strike is bright and the ring
// pure.
float bellVoice(float f, float since) {
float bend = 1.2 * exp(-since * 6.0) * sin(TAU * 2.0 * f * since);
return sin(TAU * f * since + bend);
}
// A minor pentatonic over two octaves, from A4.
float bellNote(int i) {
float notes[8] = {440.00, 523.25, 587.33, 659.25,
783.99, 880.00, 1046.50, 1174.66};
return notes[clamp(i, 0, 7)];
}
float2 sound(float t, constant Params& p) {
int now = stepAt(t, p);
float n0 = noise(t), n1 = noise(t - 1.0 / 48000.0);
float2 mix2 = float2(0.0);
for (int back = 0; back < 12; back++) {
int n = now - back;
if (n < 0) break;
// A hit twelve steps back is let go, so it fades out over the last three instead of stopping.
float since = t - stepStart(n, p);
float held = 1.0 - smoothstep(9.0, 12.0, since / stepLength(p));
if (hit(0, n, p)) {
float e = envelope(since, fadeRate(0, p));
mix2 += 0.55 * held * kickVoice(since) * e;
}
if (hit(1, n, p)) {
float tone = 0.25 * sin(TAU * 185.0 * since) * exp(-since * 30.0);
mix2 += held * (0.3 * n0 + tone) * envelope(since, fadeRate(1, p));
}
// The hat: the noise's change from one sample to the next, which keeps only its highs. A little to the right.
if (hit(2, n, p)) {
float e = envelope(since, fadeRate(2, p));
mix2 += float2(0.35, 0.65) * 0.2 * held * (n0 - n1) * e;
}
if (hit(3, n, p)) {
uint h = hashBits(uint(n) * 7u + uint(max(p.pattern, 0.0)) * 1013u);
float f = bellNote(int(h % 8u));
float pan = float((h >> 8) & 255u) / 255.0;
float e = envelope(since, fadeRate(3, p));
float ring = 0.16 * held * bellVoice(f, since) * e;
mix2 += float2(1.0 - pan * 0.6, 0.4 + pan * 0.6) * ring;
}
}
float fadeIn = smoothstep(0.0, 2.0, t);
return tanh(mix2 * 0.85) * fadeIn * p.volume;
}5 lines from stage 4 that are gone
float kick = envelope(sinceHit(0, t, p), fadeRate(0, p));
⋯
float2 q = (uv - 0.5) * u.resolution * px;
⋯
float heat = 0.04;
⋯
float heart = r - (0.2 + 0.05 * kick);
⋯
float3 col = kiln(heat);The sound
The sound is the sequencer of stage 4, worked out from the time alone and reading the same sliders as the picture: tempo, swing, density, pattern and decay change what you hear and what you see in the same way, at the same moment.
- The drums. For each of the last twelve steps, every track that hit there still sounds: the falling-sine kick, the snare's noise and tone, the hat's hiss on the right, each shaped by the same
envelopethat makes its cell flash. - The bell. A sine bent by another at twice its pitch, the bending dying away fast, so the strike is bright and the ring pure; on notes of A minor pentatonic, placed between the ears by the hash.
Everything goes through tanh and fades in over the first two seconds. At the defaults the peak is about -5.5 dBFS and the average (RMS) about -20.4 dBFS.
New in Ray Kiln
The sound drawn live. Press Sound in the playground, or under Run it live on this page, and the sound is drawn under the picture as it plays: its waveform on the left, about 21 milliseconds of it held still at a rising zero crossing, as an oscilloscope does, and its spectrum on the right, from 30 Hz to 16 kHz on a log scale, the same 256 bands a shader reading the Inputs sees. Under it are the time and the peak level of what is heard. It shows what reaches your speakers, not what is being made ahead, so it moves with the picture. It works for any shader with a sound(t, p), and is free.
This episode is made to be watched that way. Each kick shows in the spectrum as a lump at the far left that slides lower as its pitch falls, the hat as a spray at the top right, the snare as noise across the middle, and the bell as a single bright line with a ghost above it while its bending lasts. Put the waveform beside the hand: the kick's big slow wave appears just as the inner ring flashes.
Try this
- Set
swingto 0.5: every other step lands halfway to the next, a shuffle. Watch the hand wait and hurry. - Set
densityto 1 and turnpatternthrough its numbers: each is a different groove. At 0 only the fixed hits are left. - Set
decayto 1 andtempoto 70: the kick booms and the cells glow long after the hand has passed. - In
hit, change the snare'ss == 4 || s == 12tos == 8: a half-time feel, the snare once a bar. - In
kickVoice, change112.0to400.0: the kick starts higher and sounds like a tom. Watch its lump in the spectrum start further right.
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 |
|---|---|---|---|
tempo | 112 | 70 to 160 | Beats a minute: the clock both the picture and the sound keep |
swing | 0.15 | 0.0 to 0.5 | How late every other sixteenth comes, as a share of a step |
density | 0.5 | 0.0 to 1.0 | How many extra hits fill the pattern around the fixed ones |
pattern | 3 | 0 to 9 | Which pattern: every whole number is a different one |
decay | 0.5 | 0.0 to 1.0 | How long each hit rings in the sound and glows in the picture |
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-13-a-shader-that-keeps-the-beat.metal
// Ray Kiln · Episode 13 · A Shader That Keeps the Beat
//
// @episode 13 A Shader That Keeps the Beat
// @length 60
// @variation 30
// @variations 1,2,3,4,5,6,10,12
// @still 18.25
// @teaches A sequencer in the sound, the picture synced to it
// @category Fragment
// @tags sequencer, envelope, swing, polar coordinates, procedural sound
// @final Keeping the beat | The kiln's colours, the hand's trail, a shock ring on every snare and the whole drum breathing with the kick.
//
// A drum machine drawn as a clock: four rings of sixteen cells, one ring for each instrument, and a hand that sweeps
// round once a bar. Where the hand passes a lit cell, the sound plays that instrument and the cell flashes. The sound
// and the picture are never told about each other: both work out the same pattern and the same clock from the time.
// 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
// @param tempo 112 70 160 Beats a minute: the clock both the picture and the sound keep
// @param swing 0.15 0.0 0.5 How late every other sixteenth comes, as a share of a step
// @param density 0.5 0.0 1.0 How many extra hits fill the pattern around the fixed ones
// @param pattern 3 0 9 Which pattern: every whole number is a different one
// @param decay 0.5 0.0 1.0 How long each hit rings in the sound and glows in the picture
// @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;
// ---- The sequencer -------------------------------------------------------------------------------------------------
// Sixteen steps to a bar, four to a beat. Step n is counted from the start, so n / 16 is the bar and n % 16 the step
// in it. The picture and the sound both call these, with the same time, so they can't drift apart.
// Seconds a step lasts: a beat is 60 / tempo seconds, and a step a quarter of that.
float stepLength(constant Params& p) {
return 15.0 / max(p.tempo, 1.0);
}
// When step n starts. Swing: every odd step comes late, by a share of a step.
float stepStart(int n, constant Params& p) {
float late = (n % 2 == 1) ? p.swing : 0.0;
return (float(n) + late) * stepLength(p);
}
// The step playing at time t: the last one that has started.
int stepAt(float t, constant Params& p) {
int n = int(floor(t / stepLength(p)));
if (n > 0 && stepStart(n, p) > t) n -= 1;
return n;
}
// A whole-number hash, the same on every GPU: used for the pattern's dice and for noise.
uint hashBits(uint x) {
x ^= x >> 16;
x *= 0x7feb352du;
x ^= x >> 15;
x *= 0x846ca68bu;
x ^= x >> 16;
return x;
}
// Dice from 0 to 1 for one cell: the same for the same pattern, bar, track and step.
float dice(int track, int n, constant Params& p) {
uint bar = uint(n / 16) % 4u;
uint seed = uint(max(p.pattern, 0.0));
uint key = uint(track * 16 + n % 16) + bar * 64u + seed * 256u;
return float(hashBits(key + 12345u) & 0xffffu) / 65535.0;
}
// Does track hit on step n? Fixed hits, and extras the dice add, more with density.
bool hit(int track, int n, constant Params& p) {
int s = n % 16;
float r = dice(track, n, p);
float d = p.density;
if (track == 0) {
bool extra = s == 3 || s == 6 || s == 11 || s == 14;
return s % 8 == 0 || (extra && r < d * 0.7);
}
if (track == 1) {
// The last bar of the phrase may end in a fill.
bool fill = (n / 16) % 4 == 3 && s >= 13 && r < d;
return s == 4 || s == 12 || fill || (s % 2 == 1 && r < d * 0.2);
}
if (track == 2) return s % 2 == 0 || r < d;
return r < 0.12 + d * 0.3;
}
// How a hit fades: up in 2 ms (no click), then down at `rate`. The sound is shaped by it, and the picture glows by it.
float envelope(float since, float rate) {
return smoothstep(0.0, 0.002, since) * exp(-since * rate);
}
// How fast each track fades, slower with more decay.
float fadeRate(int track, constant Params& p) {
float slow = mix(2.0, 0.5, p.decay);
float rates[4] = {7.0, 14.0, 40.0, 3.0};
return rates[track] * slow;
}
// Seconds since track last hit, looking back up to twelve steps; a large number when it hasn't.
float sinceHit(int track, float t, constant Params& p) {
int now = stepAt(t, p);
for (int back = 0; back < 12; back++) {
int n = now - back;
if (n < 0) break;
if (hit(track, n, p)) return t - stepStart(n, p);
}
return 100.0;
}
// ---- The clock face ------------------------------------------------------------------------------------------------
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.76, t));
return mix(c, c4, smoothstep(0.76, 1.00, t));
}
// The radius of each track's ring: the kick inside, the bell outside.
float ringRadius(int track) {
return 0.42 + 0.2 * float(track);
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float t = u.time;
float kickSince = sinceHit(0, t, p);
float kick = envelope(kickSince, fadeRate(0, p));
float snareSince = sinceHit(1, t, p);
// The whole drum swells a little with every kick.
float px = 2.7 / min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution * px / (1.0 + 0.04 * kick);
float r = length(q);
// The angle clockwise from the top, as a share of the circle: step 0 at twelve o'clock.
float turn = fract(atan2(q.x, q.y) / TAU);
int s = min(int(turn * 16.0), 15);
float inCell = turn * 16.0 - float(s);
// The hand: where the bar is now, in steps. With swing it waits and hurries
// as the sound does.
int now = stepAt(t, p);
float a = stepStart(now, p), b = stepStart(now + 1, p);
float hand = float(now % 16) + clamp((t - a) / max(b - a, 1e-4), 0.0, 1.0);
// The background: the dark of the kiln, warm at the middle, brighter on the kick.
float heat = 0.04 + (0.12 + 0.25 * kick) * exp(-r * 2.2);
// A thin guide circle under each ring.
for (int k = 0; k < 4; k++) {
heat += 0.05 * exp(-abs(r - ringRadius(k)) * 90.0);
}
// The cells: dim, warm in this bar's pattern, flashing when hit.
int bar = now / 16;
for (int k = 0; k < 4; k++) {
float dr = abs(r - ringRadius(k)) - 0.065;
float da = (abs(inCell - 0.5) - 0.38) * (TAU / 16.0) * r;
float2 e = float2(dr, da);
float d = length(max(e, 0.0)) + min(max(dr, da), 0.0) - 0.012;
float inside = 1.0 - smoothstep(-px, px, d);
float edge = 1.0 - smoothstep(0.0, 2.0 * px, abs(d));
// The step's most recent start: this bar's if it has come, else the last bar's.
int n = bar * 16 + s;
if (n > now) n -= 16;
float flash = 0.0;
if (n >= 0 && hit(k, n, p)) {
flash = envelope(t - stepStart(n, p), fadeRate(k, p) * 0.6);
}
bool lit = hit(k, bar * 16 + s, p);
heat += edge * 0.14;
heat += inside * ((lit ? 0.36 : 0.08) + 0.6 * flash);
}
// The hand's trail: bright at the hand and fading over the steps behind it.
float behind = fract((hand - turn * 16.0) / 16.0) * 16.0;
float band = smoothstep(0.3, 0.42, r) * (1.0 - smoothstep(1.1, 1.18, r));
heat += 0.18 * exp(-behind * 1.4) * band;
float off = fract((turn * 16.0 - hand) / 16.0 + 0.5) - 0.5;
float line = off * (TAU / 16.0) * r;
float onHand = 1.0 - smoothstep(0.0, 2.5 * px, abs(line));
heat += 0.45 * onHand * step(0.3, r) * step(r, 1.16);
// The heart: a disc at the middle that beats with the kick.
float heart = length(q) - (0.2 + 0.05 * kick);
heat += (1.0 - smoothstep(-px, px, heart)) * (0.3 + 0.6 * kick);
// Every snare sends a shock ring out from the heart.
float shock = abs(r - (0.22 + snareSince * 2.2)) - 0.01;
float onShock = 1.0 - smoothstep(0.0, 6.0 * px, shock);
heat += 0.35 * exp(-snareSince * 3.0) * onShock;
// And every kick sends a soft wave out past the drum, to the edges of the picture.
float wave = abs(r - (1.2 + kickSince * 2.5));
heat += 0.1 * exp(-kickSince * 2.0) * exp(-wave * 10.0);
float3 col = kiln(heat + (p.heat - 0.5) * 0.5);
return float4(col, 1.0);
}
// ---- Sound -----------------------------------------------------------------------------------------------------
// The same sequencer, heard. For every track, the last twelve steps are looked at, and every hit among them still
// rings: its voice, from the seconds since it started, shaped by the same envelope that makes its cell glow.
// Noise, the same on every GPU: a hash of the sample's number, from -1 to 1.
float noise(float t) {
return float(hashBits(uint(t * 48000.0))) / 2147483648.0 - 1.0;
}
// The kick: a sine that falls from 160 Hz to 48 Hz. Its phase is the integral of that frequency, so it falls
// smoothly instead of chirping.
float kickVoice(float since) {
float phase = 48.0 * since + 112.0 * (1.0 - exp(-since * 30.0)) / 30.0;
return sin(TAU * phase);
}
// A bell: a sine bent by another at twice its pitch, the bending dying away fast so the strike is bright and the ring
// pure.
float bellVoice(float f, float since) {
float bend = 1.2 * exp(-since * 6.0) * sin(TAU * 2.0 * f * since);
return sin(TAU * f * since + bend);
}
// A minor pentatonic over two octaves, from A4.
float bellNote(int i) {
float notes[8] = {440.00, 523.25, 587.33, 659.25,
783.99, 880.00, 1046.50, 1174.66};
return notes[clamp(i, 0, 7)];
}
float2 sound(float t, constant Params& p) {
int now = stepAt(t, p);
float n0 = noise(t), n1 = noise(t - 1.0 / 48000.0);
float2 mix2 = float2(0.0);
for (int back = 0; back < 12; back++) {
int n = now - back;
if (n < 0) break;
// A hit twelve steps back is let go, so it fades out over the last three instead of stopping.
float since = t - stepStart(n, p);
float held = 1.0 - smoothstep(9.0, 12.0, since / stepLength(p));
if (hit(0, n, p)) {
float e = envelope(since, fadeRate(0, p));
mix2 += 0.55 * held * kickVoice(since) * e;
}
if (hit(1, n, p)) {
float tone = 0.25 * sin(TAU * 185.0 * since) * exp(-since * 30.0);
mix2 += held * (0.3 * n0 + tone) * envelope(since, fadeRate(1, p));
}
// The hat: the noise's change from one sample to the next, which keeps only its highs. A little to the right.
if (hit(2, n, p)) {
float e = envelope(since, fadeRate(2, p));
mix2 += float2(0.35, 0.65) * 0.2 * held * (n0 - n1) * e;
}
if (hit(3, n, p)) {
uint h = hashBits(uint(n) * 7u + uint(max(p.pattern, 0.0)) * 1013u);
float f = bellNote(int(h % 8u));
float pan = float((h >> 8) & 255u) / 255.0;
float e = envelope(since, fadeRate(3, p));
float ring = 0.16 * held * bellVoice(f, since) * e;
mix2 += float2(1.0 - pan * 0.6, 0.4 + pan * 0.6) * ring;
}
}
float fadeIn = smoothstep(0.0, 2.0, t);
return tanh(mix2 * 0.85) * 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] = [112, 0.15, 0.5, 3, 0.5, 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.
More in Shapes & Symmetry
Press ? for keyboard shortcuts.


