Kaleidoscope
A few glowing shapes drifting through one thin wedge of the screen, repeated and mirrored around the centre like coloured glass in a kaleidoscope, with a pattern of notes that the right ear hears in a mirror.
· Shapes & Symmetry · Fragment shader
Teaches Polar coordinates, angular repetition, mirror folds
Watch
The idea
In episodes 2 and 3, every pixel worked out where it was and drew whatever was there. A kaleidoscope cheats. Before drawing anything, each pixel is moved into one thin wedge of the screen, so the whole picture is really just that wedge, repeated and mirrored around the centre. Draw one bead in the wedge and it appears a dozen times.
The trick is to describe each pixel by which way it points and how far it is from the centre, instead of by how far across and up it is. Those are polar coordinates, and with them, cutting the screen into slices is a single line of code.
It is built in five steps, and each step is a shader you can run on its own.
Stage 1 · Angle and distance
Any point can be described by how far it is from the centre, length(q), and which way it points, atan2(q.y, q.x). The angle runs from -π to π, counted from the right. Adding the time to it turns the whole picture.
Here the angle is shown as brightness: it runs once round the circle from dark to bright, then jumps back to dark where it wraps from π to -π. That hard edge is where the numbering of the circle starts again. The distance draws a thin ring every fifth of a unit. Together they are a grid, but one made of rings and spokes instead of rows and columns.
stage-1-polar.metal
// Stage 1 · Angle and distance
// Every pixel can be named by how far it is from the centre and which way it points. Colour by both and see them.
// @param speed 0.5 0.0 2.0 How fast the pattern turns
constant float TAU = 6.2831853;
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float px = 2.4 / min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution * px;
float t = u.time * p.speed;
// Polar coordinates: the distance from the centre, and the angle from the right, from -pi to pi.
float r = length(q);
float a = atan2(q.y, q.x) + t * 0.15;
// The angle runs once round the circle, from dark to bright, with a jump where it wraps from pi back to -pi.
float angle = fract(a / TAU);
// The distance makes rings: a thin line every fifth of a unit.
float ring = smoothstep(0.42, 0.48, abs(fract(r * 5.0) - 0.5));
float3 ember = float3(1.0, 0.59, 0.2);
float3 col = ember * angle * angle + float3(1.0, 0.95, 0.87) * ring * 0.6;
return float4(col, 1.0);
}Stage 2 · Slices of a circle
Cut the circle into n equal slices, each TAU / n wide (τ is a whole turn, 2π). a -= slice floor(a / slice) subtracts as many whole slices as fit, so every angle lands in the first slice, between 0 and one slice. Turning the angle and distance back into a point with r float2(cos(a), sin(a)) gives every pixel a spot in that first slice. segments is rounded to a whole number, so the slices meet all the way round.
Now the shapes only need to be drawn in the first slice: three beads that rise from the centre and drift outwards, growing and then shrinking as they go, and a turning box. Every other slice shows the same thing. There is a flaw, though, marked by the faint lines. The far edge of one slice meets the near edge of the next, and they do not match: a shape crossing that line is simply cut off.
stage-2-slices.metal
// Stage 2 · Slices of a circle
// Cut the circle into equal slices and lay each on the first. Whatever is drawn in one slice repeats all the way round.
// @param speed 0.5 0.0 2.0 How fast the shapes drift and the pattern turns
// @param segments 6.0 2.0 16.0 How many slices go round the centre
constant float TAU = 6.2831853;
// Fold every slice onto the first, then turn the angle and distance back into a point.
float2 slices(float2 q, float n, float spin) {
float r = length(q);
float a = atan2(q.y, q.x) + spin;
float slice = TAU / n;
a -= slice * floor(a / slice);
return r * float2(cos(a), sin(a));
}
float sdCircle(float2 p, float r) {
return length(p) - r;
}
float sdBox(float2 p, float2 b) {
float2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
float2 rot(float2 p, float a) {
float c = cos(a), s = sin(a);
return float2(c * p.x - s * p.y, s * p.x + c * p.y);
}
// Where bead i is: it rises from the centre, drifts out along the slice, and sways across it as it goes.
float2 bead(int i, float t) {
float life = fract(t * 0.12 + float(i) / 3.0);
float r = 0.1 + life * 2.4;
float side = 0.5 + 0.5 * sin(t * 0.9 + float(i) * 2.1);
return float2(r, r * 0.45 * side);
}
// Three beads that grow and shrink as they drift out, and a turning box, all drawn in the first slice only.
float scene(float2 q, float t) {
float d = 1e3;
for (int i = 0; i < 3; i++) {
float life = fract(t * 0.12 + float(i) / 3.0);
d = min(d, sdCircle(q - bead(i, t), 0.05 + 0.1 * sin(3.14159 * life)));
}
float2 b = rot(q - float2(0.55, 0.0), t * 0.7);
return min(d, sdBox(b, float2(0.07, 0.07)) - 0.02);
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float px = 2.4 / min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution * px;
float t = u.time * p.speed;
// A whole number of slices, so they meet up all the way round.
float n = max(floor(p.segments + 0.5), 2.0);
float2 w = slices(q, n, t * 0.15);
// The shapes are drawn once, in the folded coordinates, and appear in every slice.
float d = scene(w, t);
float inside = 1.0 - smoothstep(-px, px, d);
// A faint line where each slice begins. A shape crossing it is cut off: the next slice does not continue it.
float seam = smoothstep(px * 2.0, 0.0, abs(w.y)) * 0.6;
float3 col = float3(0.28, 0.04, 0.03) + float3(1.0, 0.95, 0.87) * inside;
col += float3(1.0, 0.59, 0.2) * seam;
return float4(col, 1.0);
}Stage 3 · Mirrors
A real kaleidoscope uses mirrors, not copies. a = abs(a - slice * 0.5) folds each slice down its middle, so the angle runs from the slice's centre outwards in both directions, and the two halves reflect each other. Neighbouring slices now meet edge to matching edge, so the seams are gone. There are still segments slices, but each is now a mirrored pair of halves.
With mirrors, a shape that touches a mirror line meets its own reflection, and the smooth minimum from episode 2 melts the two together. A ring that gently breathes in and out joins the beads and the box. So that no bead pops in or out, its distance is pushed away as it is born and as it reaches the edge, and it fades out smoothly.
stage-3-mirror.metal
// Stage 3 · Mirrors
// Fold each slice in half, so its two halves reflect each other. The seams vanish, and the shapes become a kaleidoscope.
// @param speed 0.5 0.0 2.0 How fast the shapes drift and the pattern turns
// @param segments 6.0 2.0 16.0 How many mirrored pairs of slices go round the centre
// @param blend 0.3 0.0 1.0 How softly the shapes melt into each other
constant float TAU = 6.2831853;
// The kaleidoscope: the slices of stage 2, then a mirror down the middle of each.
float2 kaleido(float2 q, float n, float spin) {
float r = length(q);
float a = atan2(q.y, q.x) + spin;
float slice = TAU / n;
a -= slice * floor(a / slice);
a = abs(a - slice * 0.5);
return r * float2(cos(a), sin(a));
}
float sdCircle(float2 p, float r) {
return length(p) - r;
}
float sdBox(float2 p, float2 b) {
float2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
// The smooth minimum from episode 2.
float smin(float a, float b, float k) {
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * k * 0.25;
}
float2 rot(float2 p, float a) {
float c = cos(a), s = sin(a);
return float2(c * p.x - s * p.y, s * p.x + c * p.y);
}
float2 bead(int i, float t) {
float life = fract(t * 0.12 + float(i) / 3.0);
float r = 0.1 + life * 2.4;
float side = 0.5 + 0.5 * sin(t * 0.9 + float(i) * 2.1);
return float2(r, r * 0.45 * side);
}
// Three beads that grow and shrink as they drift out, a turning box and a ring, melted together.
float scene(float2 q, float t, float k) {
float d = 1e3;
for (int i = 0; i < 3; i++) {
float life = fract(t * 0.12 + float(i) / 3.0);
float size = 0.05 + 0.1 * sin(3.14159 * life);
// Fade in at the centre and out at the edge, by pushing the distance away, so no bead pops in or out.
float fade = smoothstep(0.0, 0.1, life);
fade *= 1.0 - smoothstep(0.8, 1.0, life);
d = smin(d, sdCircle(q - bead(i, t), size) + (1.0 - fade) * 0.6, k);
}
float2 b = rot(q - float2(0.55, 0.0), t * 0.7);
d = smin(d, sdBox(b, float2(0.07, 0.07)) - 0.02, k);
d = smin(d, abs(sdCircle(q, 0.95 + 0.06 * sin(t * 0.6))) - 0.015, k);
return d;
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float px = 2.4 / min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution * px;
float t = u.time * p.speed;
float n = max(floor(p.segments + 0.5), 2.0);
float2 w = kaleido(q, n, t * 0.15);
float d = scene(w, t, max(p.blend * 0.3, 0.0001));
float inside = 1.0 - smoothstep(-px, px, d);
float glow = exp(-max(d, 0.0) * 9.0);
float3 col = float3(0.28, 0.04, 0.03) * (0.4 + glow);
col += float3(1.0, 0.95, 0.87) * inside;
return float4(col, 1.0);
}Stage 4 · A twist and a zoom
Two small changes, both before the fold. Adding twist * r to the angle turns every pixel a little more the further it is from the centre, so the straight slices bend into a spiral. Negative values spiral the other way. zoom scales the size of a pixel, px: at 2, each pixel covers twice as much of the pattern, so more of it fits on the screen.
stage-4-twist.metal
// Stage 4 · A twist and a zoom
// Add to the angle a little more the further out a pixel is, and the slices bend into a spiral. Scale the pixels to zoom.
// @param speed 0.5 0.0 2.0 How fast the shapes drift and the pattern turns
// @param segments 6.0 2.0 16.0 How many mirrored pairs of slices go round the centre
// @param twist 0.6 -3.0 3.0 Bends the slices into a spiral, more the further out
// @param zoom 1.0 0.4 2.5 How much of the pattern fits on screen: higher zooms out
// @param blend 0.3 0.0 1.0 How softly the shapes melt into each other
constant float TAU = 6.2831853;
// The kaleidoscope, with a twist: the angle turns by twist * r, so the further out, the more it turns.
float2 kaleido(float2 q, float n, float spin, float twist) {
float r = length(q);
float a = atan2(q.y, q.x) + spin + twist * r;
float slice = TAU / n;
a -= slice * floor(a / slice);
a = abs(a - slice * 0.5);
return r * float2(cos(a), sin(a));
}
float sdCircle(float2 p, float r) {
return length(p) - r;
}
float sdBox(float2 p, float2 b) {
float2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
// The smooth minimum from episode 2.
float smin(float a, float b, float k) {
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * k * 0.25;
}
float2 rot(float2 p, float a) {
float c = cos(a), s = sin(a);
return float2(c * p.x - s * p.y, s * p.x + c * p.y);
}
float2 bead(int i, float t) {
float life = fract(t * 0.12 + float(i) / 3.0);
float r = 0.1 + life * 2.4;
float side = 0.5 + 0.5 * sin(t * 0.9 + float(i) * 2.1);
return float2(r, r * 0.45 * side);
}
// Three beads that grow and shrink as they drift out, a turning box and a ring, melted together.
float scene(float2 q, float t, float k) {
float d = 1e3;
for (int i = 0; i < 3; i++) {
float life = fract(t * 0.12 + float(i) / 3.0);
float size = 0.05 + 0.1 * sin(3.14159 * life);
// Fade in at the centre and out at the edge, by pushing the distance away, so no bead pops in or out.
float fade = smoothstep(0.0, 0.1, life);
fade *= 1.0 - smoothstep(0.8, 1.0, life);
d = smin(d, sdCircle(q - bead(i, t), size) + (1.0 - fade) * 0.6, k);
}
float2 b = rot(q - float2(0.55, 0.0), t * 0.7);
d = smin(d, sdBox(b, float2(0.07, 0.07)) - 0.02, k);
d = smin(d, abs(sdCircle(q, 0.95 + 0.06 * sin(t * 0.6))) - 0.015, k);
return d;
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
// Zoom scales every pixel: at 2, each pixel covers twice as much of the pattern.
float px = 2.4 * p.zoom / min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution * px;
float t = u.time * p.speed;
float n = max(floor(p.segments + 0.5), 2.0);
float2 w = kaleido(q, n, t * 0.15, p.twist);
float d = scene(w, t, max(p.blend * 0.3, 0.0001));
float inside = 1.0 - smoothstep(-px, px, d);
float glow = exp(-max(d, 0.0) * 9.0);
float3 col = float3(0.28, 0.04, 0.03) * (0.4 + glow);
col += float3(1.0, 0.95, 0.87) * inside;
return float4(col, 1.0);
}Stage 5 · Kiln glass
The last step colours the folded shapes with the kiln palette from episode 1, the same way episode 2 coloured its distance field. Inside, each shape is hotter towards its middle. Outside, there is a glow, and rings that ripple out from the edges. Behind it all, episode 1's flowing noise is sampled at the folded point, so the smoke is mirrored too. The picture darkens towards the edge of the screen, like the end of the tube, and heat slides it all along the palette.
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 px = 2.4 * p.zoom / min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution * px;
float t = u.time * p.speed;
float n = max(floor(p.segments + 0.5), 2.0);
float2 w = kaleido(q, n, t * 0.15, p.twist);
float k = max(p.blend * 0.3, 0.0001);
float d = scene(w, t, k);
// Inside: hotter towards the middle of each shape.
float core = 0.62 + 0.38 * clamp(-d * 8.0, 0.0, 1.0);
// Outside: flowing noise in the folded space, so it is mirrored too, then a glow and rings around the shapes.
float smoke = fbm(w * 3.0 + float2(-t * 0.4, t * 0.15));
float glow = exp(-max(d, 0.0) * 9.0);
float ring = 0.5 + 0.5 * cos(TAU * (14.0 * d - t * 0.8));
float outside = 0.04 + 0.22 * smoke + 0.34 * glow;
outside += 0.12 * ring * exp(-max(d, 0.0) * 5.0);
// Darker towards the edge of the screen, like looking down the tube.
outside *= 1.0 - 0.35 * smoothstep(0.8, 1.8, length(q) / p.zoom);
float heat = mix(core, outside, smoothstep(-px, px, d));
float3 col = kiln(heat + (p.heat - 0.5) * 0.5);
return float4(col, 1.0);
}The sound
The sound uses the same ideas, in time instead of space. Each slice of the circle has a note, from D minor pentatonic starting at D4 (D, F, G, A, C and up), and the notes play in turn, segments of them before the pattern repeats: two notes a second at the default speed. That is angular repetition, with time going round instead of the angle.
The mirror is between the ears. When the left ear plays the first note, the right plays the last, so the right ear hears the left ear's pattern backwards. Each ear also hears a little of the other, so it sounds like one instrument in a mirror rather than two. Each note rings on as the next one starts, so none is cut off.
Under it, a low drone on D2 and A2, a few cents apart in each ear, breathes on the same clock as the ring in the picture.
Try this
- Set
segmentsto 2: one mirror down the middle of the screen. Then go up to 16 and listen to the pattern get longer. - Set
twistto 3 for a tight spiral, or -3 to turn it the other way. - Set
zoomto 2.5 to see the whole pattern, small and far away. - In stage 2, change the first slice's shapes and watch every slice follow.
- Remove the
absinkaleidoand the seams from stage 2 come back.
Parameters
| Parameter | Default | Range | What it does |
|---|---|---|---|
speed | 0.5 | 0.0 to 2.0 | How fast the shapes drift and the pattern turns |
segments | 6.0 | 2.0 to 16.0 | How many mirrored pairs of slices go round the centre |
twist | 0.6 | -3.0 to 3.0 | Bends the slices into a spiral, more the further out |
zoom | 1.0 | 0.4 to 2.5 | How much of the pattern fits on screen: higher zooms out |
blend | 0.3 | 0.0 to 1.0 | How softly the shapes melt into each other |
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.
The full source
This is the whole episode: the picture, the parameters and the sound.
episode-04-kaleidoscope.metal
// Ray Kiln · Episode 4 · Kaleidoscope
//
// @episode 4 Kaleidoscope
// @length 60
// @variation 30
// @variations 1,2,4,5,6,9,10,11
// @still 18.0
// @teaches Polar coordinates, angular repetition, mirror folds
// @category Fragment
// @tags polar coordinates, kaleidoscope, symmetry, signed distance fields, procedural sound
// @final Kiln glass | Colour the folded shapes with the kiln palette, glow and rings around them, and flowing noise behind.
//
// A handful of glowing shapes drifting through one thin wedge of the screen, mirrored and repeated around the centre
// until they fill it, like coloured glass between the mirrors of a kaleidoscope.
// 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 speed 0.5 0.0 2.0 How fast the shapes drift and the pattern turns
// @param segments 6.0 2.0 16.0 How many mirrored pairs of slices go round the centre
// @param twist 0.6 -3.0 3.0 Bends the slices into a spiral, more the further out
// @param zoom 1.0 0.4 2.5 How much of the pattern fits on screen: higher zooms out
// @param blend 0.3 0.0 1.0 How softly the shapes melt into each other
// @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;
// ---- Folding ---------------------------------------------------------------------------------------------------
// The kaleidoscope: turn a point into an angle and a radius, cut the circle into equal slices, put every slice on top
// of the first, mirror every other one, and turn the angle and radius back into a point.
float2 kaleido(float2 q, float n, float spin, float twist) {
float r = length(q);
float a = atan2(q.y, q.x) + spin + twist * r;
float slice = TAU / n;
// Angular repetition: every slice lands on the first.
a -= slice * floor(a / slice);
// Mirror fold: the two halves of a slice reflect each other.
a = abs(a - slice * 0.5);
return r * float2(cos(a), sin(a));
}
// ---- Shapes (episode 2) ----------------------------------------------------------------------------------------
float sdCircle(float2 p, float r) {
return length(p) - r;
}
float sdBox(float2 p, float2 b) {
float2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
float smin(float a, float b, float k) {
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * k * 0.25;
}
float2 rot(float2 p, float a) {
float c = cos(a), s = sin(a);
return float2(c * p.x - s * p.y, s * p.x + c * p.y);
}
// Where bead i is: it rises from the centre, drifts out along the slice, and sways across it as it goes.
float2 bead(int i, float t) {
float life = fract(t * 0.12 + float(i) / 3.0);
float r = 0.1 + life * 2.4;
float side = 0.5 + 0.5 * sin(t * 0.9 + float(i) * 2.1);
return float2(r, r * 0.45 * side);
}
// Everything in the wedge: three drifting beads, a turning box and a ring, melted together.
float scene(float2 q, float t, float k) {
float d = 1e3;
for (int i = 0; i < 3; i++) {
float life = fract(t * 0.12 + float(i) / 3.0);
float size = 0.05 + 0.1 * sin(3.14159 * life);
// Fade in at the centre and out at the edge, by pushing the distance away, so no bead pops in or out.
float fade = smoothstep(0.0, 0.1, life);
fade *= 1.0 - smoothstep(0.8, 1.0, life);
d = smin(d, sdCircle(q - bead(i, t), size) + (1.0 - fade) * 0.6, k);
}
float2 b = rot(q - float2(0.55, 0.0), t * 0.7);
d = smin(d, sdBox(b, float2(0.07, 0.07)) - 0.02, k);
d = smin(d, abs(sdCircle(q, 0.95 + 0.06 * sin(t * 0.6))) - 0.015, k);
return d;
}
// ---- Colour (episode 1) ----------------------------------------------------------------------------------------
float hash21(float2 p) {
p = fract(p * float2(123.34, 456.21));
p += dot(p, p + 45.32);
return fract(p.x * p.y);
}
float vnoise(float2 p) {
float2 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash21(i), b = hash21(i + float2(1, 0));
float c = hash21(i + float2(0, 1)), d = hash21(i + float2(1, 1));
return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}
float fbm(float2 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 4; i++) {
v += a * vnoise(p);
p = p * 2.03 + float2(17.1, 9.2);
a *= 0.5;
}
return v;
}
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 px = 2.4 * p.zoom / min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution * px;
float t = u.time * p.speed;
float n = max(floor(p.segments + 0.5), 2.0);
float2 w = kaleido(q, n, t * 0.15, p.twist);
float k = max(p.blend * 0.3, 0.0001);
float d = scene(w, t, k);
// Inside: hotter towards the middle of each shape.
float core = 0.62 + 0.38 * clamp(-d * 8.0, 0.0, 1.0);
// Outside: flowing noise in the folded space, so it is mirrored too, then a glow and rings around the shapes.
float smoke = fbm(w * 3.0 + float2(-t * 0.4, t * 0.15));
float glow = exp(-max(d, 0.0) * 9.0);
float ring = 0.5 + 0.5 * cos(TAU * (14.0 * d - t * 0.8));
float outside = 0.04 + 0.22 * smoke + 0.34 * glow;
outside += 0.12 * ring * exp(-max(d, 0.0) * 5.0);
// Darker towards the edge of the screen, like looking down the tube.
outside *= 1.0 - 0.35 * smoothstep(0.8, 1.8, length(q) / p.zoom);
float heat = mix(core, outside, smoothstep(-px, px, d));
float3 col = kiln(heat + (p.heat - 0.5) * 0.5);
return float4(col, 1.0);
}
// ---- Sound -----------------------------------------------------------------------------------------------------
// The same ideas, in time instead of space. A pattern of notes repeats, one note for each slice of the circle, like the
// slices of the picture. The right ear hears the left ear's pattern in a mirror: the same notes in reverse order. Under
// them a low drone on D, and the ring in the picture swells it as it breathes.
// D minor pentatonic, from D4 up.
float scaleNote(int i) {
const float notes[16] = {293.66, 349.23, 392.00, 440.00, 523.25, 587.33, 698.46, 783.99,
880.00, 1046.50, 1174.66, 1396.91, 1567.98, 1760.00, 2093.00, 2349.32};
return notes[clamp(i, 0, 15)];
}
// A soft plucked note: a sine and its octave, starting quickly and dying away.
float pluck(float f, float since, float t) {
float env = (1.0 - exp(-since * 250.0)) * exp(-since * 5.0);
return (sin(TAU * f * t) + 0.3 * sin(TAU * 2.0 * f * t)) * env;
}
float2 sound(float t, constant Params& p) {
float ts = t * p.speed;
int n = int(max(floor(p.segments + 0.5), 2.0));
// Two notes a second at the default speed (four at speed 1), n notes in each turn of the pattern.
// The note before still rings as the next begins, so it is heard dying away rather than cut off.
float steps = ts * 4.0;
float stepLength = 1.0 / max(p.speed * 4.0, 0.001); // seconds per note
float left = 0.0, right = 0.0;
for (int back = 0; back < 2; back++) {
int j = (int(floor(steps)) - back + n * 1000) % n;
// Seconds since that note began.
float since = (fract(steps) + float(back)) * stepLength;
left += pluck(scaleNote(j), since, t);
right += pluck(scaleNote(n - 1 - j), since, t);
}
// Each ear hears a little of the other, so the mirror sounds like one instrument, not two.
float2 arp = float2(left + 0.3 * right, right + 0.3 * left) * 0.09;
// The drone: D2 and A2, a few cents apart in each ear, breathing with the ring.
float breath = 0.7 + 0.3 * sin(ts * 0.6);
float2 drone = float2(sin(TAU * 73.42 * 1.001 * t) + 0.6 * sin(TAU * 110.0 * 1.001 * t),
sin(TAU * 73.42 * 0.999 * t + 1.0) + 0.6 * sin(TAU * 110.0 * 0.999 * t + 1.0));
drone *= breath * 0.12;
float fadeIn = smoothstep(0.0, 2.0, t);
return tanh((arp + drone) * 1.6) * 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, 6.0, 0.6, 1.0, 0.3, 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.
