Shader 03

Floating 3D Scene

A sphere, a ring and a tumbling box floating in the dark, drawn by marching a ray through every pixel and lit by which way each surface faces, with a chord in which every shape has a voice.

FragmentRaymarchingnormalsbasic lighting#signed distance fields#lighting#procedural sound2026-10-06

Watch

The idea

Episode 2 drew shapes from their distance functions on a flat screen. This time the same idea goes one dimension up. Every distance function now takes a point in space, and each pixel sends a ray out from a camera into the scene. Distances tell the ray how far it can safely step, so it hops forward until it lands on a surface. Once it lands, the same distances tell us which way that surface faces, and that is all lighting needs.

It is built in five steps, and each step is a shader you can run on its own.

Stage 1 · A ray for every pixel

The camera sits 4 units back from the middle of the scene. Each pixel's ray leaves the camera towards the screen, which is 2 units in front of it: the pixel's q.x and q.y across, -2 ahead, made one unit long by normalize. A bigger number there would zoom in, and a smaller one would widen the view.

The sphere's distance function is the circle's from episode 2, with one more axis: length(p) - r. It answers one question. From here, how far is the nearest point of the sphere? Nothing can be closer than that, so the ray can step that far without passing through the surface. Then it asks again from its new spot. This is raymarching, also called sphere tracing. Near the surface the steps shrink, and once the distance is smaller than a tiny fraction of how far the ray has travelled, the ray has hit. A ray that goes 20 units without hitting anything has missed. Pixels whose ray hit are white, and the others stay black.

stage-1-ray.metal
// Stage 1 · A ray for every pixel
// A camera sends a ray through each pixel. March along it, and paint the pixel white if the ray reaches the sphere.

// A sphere's distance, like episode 2's circle: to its centre, minus its radius.
float sdSphere(float3 p, float r) {
    return length(p) - r;
}

float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
    float side = min(u.resolution.x, u.resolution.y);
    float2 q = (uv - 0.5) * u.resolution / side * 2.0;
    // Each pixel's ray leaves the camera towards the screen, 2 units ahead.
    float3 ro = float3(0.0, 0.0, 4.0);
    float3 rd = normalize(float3(q, -2.0));
    // March: step as far as the distance allows. Close enough is a hit, too far a miss.
    float dist = 0.0;
    bool hit = false;
    for (int i = 0; i < 128; i++) {
        float d = sdSphere(ro + rd * dist, 0.6);
        if (d < 0.001 * dist) { hit = true; break; }
        dist += d;
        if (dist > 20.0) break;
    }
    return float4(float3(hit ? 1.0 : 0.0), 1.0);
}

Stage 2 · A scene of shapes

map is the distance to the whole scene: the nearest of a sphere, a ring and a box. sdTorus measures a ring lying flat. It finds how far the point is from the ring's centre line in the flat plane, then subtracts the thickness of the tube. sdBox is episode 2's box with a third axis, and subtracting 0.05 rounds its corners. The ring joins the sphere with episode 2's smin, so with blend up the two melt together like wax. The box joins with a plain min.

To move a shape, move the point instead. p - spherePos(...) measures from the sphere's centre, and rot turns the point the other way so the ring tilts and the box tumbles. The sphere and ring bob out of step, and the box orbits them. lift sets how far they bob.

The camera now stands above the scene and looks down at its middle. fw points ahead, rt to the right (from a cross product with "up"), and up completes the set. Each ray is a mix of the three. The picture is shaded by how far the ray travelled before it hit. Near surfaces are bright and far ones dim, which already shows the scene is 3D.

stage-2-scene.metal
// Stage 2 · A scene of shapes
// A sphere, a ring and a box, joined into one distance. Shading by how far each ray travelled shows their depth.

// @param speed 0.5 0.0 2.0 How fast the shapes float
// @param lift 1.0 0.0 2.0 How far the shapes bob up and down
// @param blend 0.3 0.0 1.0 How softly the ring melts into the sphere

float sdSphere(float3 p, float r) {
    return length(p) - r;
}

// A ring lying flat: R is its radius, r the thickness of its tube.
float sdTorus(float3 p, float R, float r) {
    float2 q = float2(length(p.xz) - R, p.y);
    return length(q) - r;
}

// The box from episode 2, with a third axis.
float sdBox(float3 p, float3 b) {
    float3 d = abs(p) - b;
    return length(max(d, 0.0)) + min(max(d.x, max(d.y, d.z)), 0.0);
}

// Episode 2's smooth minimum, to melt shapes together.
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;
}

// Turn a pair of coordinates by angle a.
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 each shape is at time t: the sphere and the ring bob out of step, the box orbits them.
float3 spherePos(float t, float lift) {
    return float3(0.0, 0.22 * lift * sin(t * 1.3), 0.0);
}

float3 ringPos(float t, float lift) {
    return float3(0.0, 0.22 * lift * sin(t * 1.3 - 1.4), 0.0);
}

float3 boxPos(float t, float lift) {
    float a = t * 0.5;
    float y = 0.3 + 0.3 * lift * sin(t * 1.7);
    return float3(1.9 * cos(a), y, 1.9 * sin(a));
}

// The whole scene is one distance: how far p is from the nearest surface.
float map(float3 p, float t, float lift, float k) {
    float d = sdSphere(p - spherePos(t, lift), 0.6);
    // The ring tilts back and forth and spins around its own axis.
    float3 q = p - ringPos(t, lift);
    q.yz = rot(q.yz, 0.45 + 0.35 * sin(t * 0.7));
    q.xz = rot(q.xz, t * 0.6);
    d = smin(d, sdTorus(q, 0.86, 0.12), k);
    // The box tumbles as it goes, its corners rounded off.
    float3 b = p - boxPos(t, lift);
    b.xy = rot(b.xy, t * 0.9);
    b.yz = rot(b.yz, t * 0.7);
    d = min(d, sdBox(b, float3(0.26)) - 0.05);
    return d;
}

float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
    float t = u.time * p.speed;
    float k = max(p.blend * 0.5, 0.0001);
    float side = min(u.resolution.x, u.resolution.y);
    float2 q = (uv - 0.5) * u.resolution / side * 2.0;
    // The camera now stands a little above the scene and turns to look at its middle:
    // fw points ahead, rt to the right, up upwards, and each ray is a mix of the three.
    float3 ro = float3(0.0, 1.2, 3.9);
    float3 fw = normalize(-ro);
    float3 rt = normalize(cross(fw, float3(0.0, 1.0, 0.0)));
    float3 up = cross(rt, fw);
    float3 rd = normalize(q.x * rt + q.y * up + 2.0 * fw);
    float dist = 0.0;
    bool hit = false;
    for (int i = 0; i < 128; i++) {
        float d = map(ro + rd * dist, t, p.lift, k);
        if (d < 0.001 * dist) { hit = true; break; }
        dist += d;
        if (dist > 20.0) break;
    }
    // Brighter the nearer the surface: the distance the ray travelled is the depth of the scene.
    float depth = hit ? 1.0 - smoothstep(2.0, 7.0, dist) : 0.0;
    return float4(float3(0.15 + 0.85 * depth) * float(hit), 1.0);
}

Stage 3 · Which way the surface faces

To light a surface, you need to know which way it faces: its normal. The distance field already holds that. Step a tiny bit along x, y and z on each side of the hit point, and see how much the distance changes. The distance grows fastest straight out of the surface, so those three changes, made one unit long, give the normal. That is normalAt, built from six calls to map. e is (0.001, 0), so e.xyy is the small step (0.001, 0, 0) along x, e.yxy along y and e.yyx along z.

Here the normal is shown as a colour, each part moved from -1…1 to 0…1. Red faces right, green faces up and blue faces the camera, so the box's faces each have a flat colour and the sphere and ring shade smoothly through them all.

stage-3-normals.metal
// Stage 3 · Which way the surface faces
// Where a ray lands, the distance field tells us which way the surface points. Show that direction as a colour.

// @param speed 0.5 0.0 2.0 How fast the shapes float
// @param lift 1.0 0.0 2.0 How far the shapes bob up and down
// @param blend 0.3 0.0 1.0 How softly the ring melts into the sphere

float sdSphere(float3 p, float r) {
    return length(p) - r;
}

// A ring lying flat: R is its radius, r the thickness of its tube.
float sdTorus(float3 p, float R, float r) {
    float2 q = float2(length(p.xz) - R, p.y);
    return length(q) - r;
}

// The box from episode 2, with a third axis.
float sdBox(float3 p, float3 b) {
    float3 d = abs(p) - b;
    return length(max(d, 0.0)) + min(max(d.x, max(d.y, d.z)), 0.0);
}

// Episode 2's smooth minimum, to melt shapes together.
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;
}

// Turn a pair of coordinates by angle a.
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 each shape is at time t: the sphere and the ring bob out of step, the box orbits them.
float3 spherePos(float t, float lift) {
    return float3(0.0, 0.22 * lift * sin(t * 1.3), 0.0);
}

float3 ringPos(float t, float lift) {
    return float3(0.0, 0.22 * lift * sin(t * 1.3 - 1.4), 0.0);
}

float3 boxPos(float t, float lift) {
    float a = t * 0.5;
    float y = 0.3 + 0.3 * lift * sin(t * 1.7);
    return float3(1.9 * cos(a), y, 1.9 * sin(a));
}

// The whole scene is one distance: how far p is from the nearest surface.
float map(float3 p, float t, float lift, float k) {
    float d = sdSphere(p - spherePos(t, lift), 0.6);
    // The ring tilts back and forth and spins around its own axis.
    float3 q = p - ringPos(t, lift);
    q.yz = rot(q.yz, 0.45 + 0.35 * sin(t * 0.7));
    q.xz = rot(q.xz, t * 0.6);
    d = smin(d, sdTorus(q, 0.86, 0.12), k);
    // The box tumbles as it goes, its corners rounded off.
    float3 b = p - boxPos(t, lift);
    b.xy = rot(b.xy, t * 0.9);
    b.yz = rot(b.yz, t * 0.7);
    d = min(d, sdBox(b, float3(0.26)) - 0.05);
    return d;
}

// The normal: how the distance changes for a small step along each axis. e.xyy is a step along x.
float3 normalAt(float3 p, float t, float lift, float k) {
    const float2 e = float2(0.001, 0.0);
    return normalize(float3(
        map(p + e.xyy, t, lift, k) - map(p - e.xyy, t, lift, k),
        map(p + e.yxy, t, lift, k) - map(p - e.yxy, t, lift, k),
        map(p + e.yyx, t, lift, k) - map(p - e.yyx, t, lift, k)));
}

float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
    float t = u.time * p.speed;
    float k = max(p.blend * 0.5, 0.0001);
    float side = min(u.resolution.x, u.resolution.y);
    float2 q = (uv - 0.5) * u.resolution / side * 2.0;
    float3 ro = float3(0.0, 1.2, 3.9);
    float3 fw = normalize(-ro);
    float3 rt = normalize(cross(fw, float3(0.0, 1.0, 0.0)));
    float3 up = cross(rt, fw);
    float3 rd = normalize(q.x * rt + q.y * up + 2.0 * fw);
    float dist = 0.0;
    bool hit = false;
    for (int i = 0; i < 128; i++) {
        float d = map(ro + rd * dist, t, p.lift, k);
        if (d < 0.001 * dist) { hit = true; break; }
        dist += d;
        if (dist > 20.0) break;
    }
    if (!hit) return float4(0.0, 0.0, 0.0, 1.0);
    float3 n = normalAt(ro + rd * dist, t, p.lift, k);
    // The normal's parts, -1 to 1, become colours: red faces right, green up, blue the camera.
    return float4(n * 0.5 + 0.5, 1.0);
}

Stage 4 · Light

The simplest lighting is diffuse light. A surface facing the light straight on gets all of it. One turned side-on gets none, and one turned away stays dark. dot(n, l) gives exactly that: 1 when the normal points at the light, 0 when it is side-on, negative beyond, so it is clamped at 0. The light stands high up on a circle around the scene, and light walks it around that circle. A little light is added everywhere so the dark side keeps its shape.

stage-4-light.metal
// Stage 4 · Light
// A surface is as bright as it faces the light: the dot product of its normal and the light's direction.

// @param speed 0.5 0.0 2.0 How fast the shapes float
// @param lift 1.0 0.0 2.0 How far the shapes bob up and down
// @param blend 0.3 0.0 1.0 How softly the ring melts into the sphere
// @param light 0.15 0.0 1.0 Where the light stands, once around the scene

float sdSphere(float3 p, float r) {
    return length(p) - r;
}

// A ring lying flat: R is its radius, r the thickness of its tube.
float sdTorus(float3 p, float R, float r) {
    float2 q = float2(length(p.xz) - R, p.y);
    return length(q) - r;
}

// The box from episode 2, with a third axis.
float sdBox(float3 p, float3 b) {
    float3 d = abs(p) - b;
    return length(max(d, 0.0)) + min(max(d.x, max(d.y, d.z)), 0.0);
}

// Episode 2's smooth minimum, to melt shapes together.
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;
}

// Turn a pair of coordinates by angle a.
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 each shape is at time t: the sphere and the ring bob out of step, the box orbits them.
float3 spherePos(float t, float lift) {
    return float3(0.0, 0.22 * lift * sin(t * 1.3), 0.0);
}

float3 ringPos(float t, float lift) {
    return float3(0.0, 0.22 * lift * sin(t * 1.3 - 1.4), 0.0);
}

float3 boxPos(float t, float lift) {
    float a = t * 0.5;
    float y = 0.3 + 0.3 * lift * sin(t * 1.7);
    return float3(1.9 * cos(a), y, 1.9 * sin(a));
}

// The whole scene is one distance: how far p is from the nearest surface.
float map(float3 p, float t, float lift, float k) {
    float d = sdSphere(p - spherePos(t, lift), 0.6);
    // The ring tilts back and forth and spins around its own axis.
    float3 q = p - ringPos(t, lift);
    q.yz = rot(q.yz, 0.45 + 0.35 * sin(t * 0.7));
    q.xz = rot(q.xz, t * 0.6);
    d = smin(d, sdTorus(q, 0.86, 0.12), k);
    // The box tumbles as it goes, its corners rounded off.
    float3 b = p - boxPos(t, lift);
    b.xy = rot(b.xy, t * 0.9);
    b.yz = rot(b.yz, t * 0.7);
    d = min(d, sdBox(b, float3(0.26)) - 0.05);
    return d;
}

// The normal: how the distance changes for a small step along each axis. e.xyy is a step along x.
float3 normalAt(float3 p, float t, float lift, float k) {
    const float2 e = float2(0.001, 0.0);
    return normalize(float3(
        map(p + e.xyy, t, lift, k) - map(p - e.xyy, t, lift, k),
        map(p + e.yxy, t, lift, k) - map(p - e.yxy, t, lift, k),
        map(p + e.yyx, t, lift, k) - map(p - e.yyx, t, lift, k)));
}

float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
    float t = u.time * p.speed;
    float k = max(p.blend * 0.5, 0.0001);
    float side = min(u.resolution.x, u.resolution.y);
    float2 q = (uv - 0.5) * u.resolution / side * 2.0;
    float3 ro = float3(0.0, 1.2, 3.9);
    float3 fw = normalize(-ro);
    float3 rt = normalize(cross(fw, float3(0.0, 1.0, 0.0)));
    float3 up = cross(rt, fw);
    float3 rd = normalize(q.x * rt + q.y * up + 2.0 * fw);
    float dist = 0.0;
    bool hit = false;
    for (int i = 0; i < 128; i++) {
        float d = map(ro + rd * dist, t, p.lift, k);
        if (d < 0.001 * dist) { hit = true; break; }
        dist += d;
        if (dist > 20.0) break;
    }
    if (!hit) return float4(0.0, 0.0, 0.0, 1.0);
    float3 n = normalAt(ro + rd * dist, t, p.lift, k);
    // The light, high up, on a circle around the scene.
    float a = p.light * 6.2831853;
    float3 l = normalize(float3(cos(a), 1.1, sin(a)));
    // Facing the light, dot(n, l) is 1; side-on it is 0; turned away it is negative, so clamp it to 0.
    float diffuse = max(dot(n, l), 0.0);
    // A little light everywhere, so the dark side is not lost.
    float level = 0.1 + 0.9 * diffuse;
    return float4(float3(level), 1.0);
}

Stage 5 · Kiln light

The last step lights the scene like the inside of a kiln and colours it with the kiln palette from episode 1. Every light is added into one number, level, and the palette turns that number into a colour. Dim light shows as deep ember, more light as orange and gold, and the brightest light as white-hot.

  • The warm light is stage 4's diffuse light.
  • The kiln's glow rises from below: max(-n.y, 0.0) is how much a surface faces down, so the undersides glow ember instead of going black.
  • The highlight is the light's reflection. h is halfway between the direction to the light and the direction to the camera. Where the normal lines up with h, the surface would mirror the light into the camera. Raising that to the power shine keeps only where it lines up almost exactly, so a higher shine gives a smaller, sharper highlight, like glaze instead of clay.
  • The glow around the shapes comes from the march. The loop keeps closest, the nearest the ray came to any surface. A ray that missed but passed close brightens the background a little, so every shape has a halo.

Behind everything, the background warms towards the bottom of the picture. The camera now circles the scene too, so you can see it from every side.

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.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 * p.speed;
    float k = max(p.blend * 0.5, 0.0001);

    // A ray for this pixel, from a camera that looks at the middle of the scene.
    float side = min(u.resolution.x, u.resolution.y);
    float2 q = (uv - 0.5) * u.resolution / side * 2.0;
    float3 ro = cameraPos(t);
    float3 fw = normalize(-ro);
    float3 rt = normalize(cross(fw, float3(0.0, 1.0, 0.0)));
    float3 up = cross(rt, fw);
    float3 rd = normalize(q.x * rt + q.y * up + 2.0 * fw);

    // March: step as far as the nearest surface, again and again, until the ray touches one or leaves the scene.
    // Remember how close the ray came to anything, for the glow around the shapes.
    float dist = 0.0;
    float closest = 1e9;
    bool hit = false;
    for (int i = 0; i < 128; i++) {
        float d = map(ro + rd * dist, t, p.lift, k);
        closest = min(closest, d);
        if (d < 0.001 * dist) { hit = true; break; }
        dist += d;
        if (dist > 20.0) break;
    }

    // Behind the shapes: dark, warmer towards the bottom where the kiln is hottest, with a glow around each shape.
    float low = smoothstep(0.1, -0.8, rd.y);
    float halo = exp(-max(closest, 0.0) * 10.0);
    float level = 0.03 + 0.12 * low + 0.28 * halo;

    if (hit) {
        float3 pos = ro + rd * dist;
        float3 n = normalAt(pos, t, p.lift, k);
        // The warm light, high up, on a circle around the scene.
        float a = p.light * 6.2831853;
        float3 l = normalize(float3(cos(a), 1.1, sin(a)));
        // Diffuse: a surface is as bright as it faces the light.
        float diffuse = max(dot(n, l), 0.0);
        // The kiln's glow, rising from below onto every surface that faces down.
        float below = max(-n.y, 0.0);
        // A highlight where the surface would mirror the light into the camera.
        float3 h = normalize(l - rd);
        float spec = pow(max(dot(n, h), 0.0), p.shine) * step(0.0, dot(n, l));
        level = 0.12 + 0.55 * diffuse + 0.22 * below + 0.6 * spec;
    }

    float3 col = kiln(level + (p.heat - 0.5) * 0.5);
    return float4(col, 1.0);
}

The sound

Every shape has a voice, and the three together make a D major chord. The sound runs the same position functions as the picture, so each voice follows its own shape.

The sphere hums a low D (D2 and D3, a few cents apart in each ear). The ring sings the A above it, and the box an F♯ higher still. Each voice is a sine with a few of its overtones mixed in. The higher its shape floats, towards the light, the more overtones it gets, so it sounds brighter and fuller at the top of its bob.

The box is heard from where it is on the screen. The sound projects its position onto the camera's right-hand direction to pan it left or right. It also gets louder as the box swings close to the camera, so it passes by in front of you. The ring swells and shimmers as it comes close to the sphere, where blend melts them together.

Try this

  • Set blend to 1 and watch the ring melt into the sphere as they bob into each other.
  • Move light slowly from 0 to 1 and watch the lit side, and the highlights, travel around the shapes.
  • Set shine to 4 for a wide, soft sheen, or to 160 for a pinpoint of glaze.
  • Set speed to 0 to freeze the scene, then add your own shape to map. A second sphere is one line: d = min(d, sdSphere(p - float3(-1.6, 0.4, 0.6), 0.3));.

Parameters

ParameterDefaultRangeWhat it does
speed0.50.0 to 2.0How fast the shapes float and the camera circles
lift1.00.0 to 2.0How far the shapes bob up and down
blend0.30.0 to 1.0How softly the ring melts into the sphere
light0.150.0 to 1.0Where the warm light stands, once around the scene
shine40.04.0 to 160.0How small and sharp the highlights are
heat0.50.0 to 1.0Slides the palette from deep ember to white-hot
volume0.80.0 to 1.0Loudness of the sound

More from this shader

The same shader with different settings, each from a different moment, played back to back. The settings of each one are in the video's chapters.

The full source

This is the whole episode: the picture, the parameters and the sound.

week-03-floating-3d-scene.metal
// Ray Kiln · Episode 3 · Floating 3D Scene
//
// @episode 3 Floating 3D Scene
// @length 60
// @short 30
// @still 21.0
// @teaches Raymarching, normals, basic lighting
// @category Fragment
// @tags raymarching, signed distance fields, normals, lighting, procedural sound
// @final Kiln light | A warm light from above, the kiln's glow from below, a highlight, and a camera that circles the scene.
//
// A sphere, a ring and a tumbling box floating in the dark, drawn by marching a ray from the camera through every
// pixel until it meets a surface, then lit by how that surface faces the light.
// 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 float and the camera circles
// @param lift 1.0 0.0 2.0 How far the shapes bob up and down
// @param blend 0.3 0.0 1.0 How softly the ring melts into the sphere
// @param light 0.15 0.0 1.0 Where the warm light stands, once around the scene
// @param shine 40.0 4.0 160.0 How small and sharp the highlights are
// @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

// ---- Shapes ----------------------------------------------------------------------------------------------------
// The same idea as episode 2, one dimension up: each shape is a function that says how far a point is from it.

float sdSphere(float3 p, float r) {
    return length(p) - r;
}

// A ring lying flat: R is its radius, r the thickness of its tube.
float sdTorus(float3 p, float R, float r) {
    float2 q = float2(length(p.xz) - R, p.y);
    return length(q) - r;
}

float sdBox(float3 p, float3 b) {
    float3 d = abs(p) - b;
    return length(max(d, 0.0)) + min(max(d.x, max(d.y, d.z)), 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;
}

// Turn a pair of coordinates by angle a.
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 each shape is at time t. The sound uses these too.
float3 spherePos(float t, float lift) {
    return float3(0.0, 0.22 * lift * sin(t * 1.3), 0.0);
}

float3 ringPos(float t, float lift) {
    return float3(0.0, 0.22 * lift * sin(t * 1.3 - 1.4), 0.0);
}

float3 boxPos(float t, float lift) {
    float a = t * 0.5;
    float y = 0.3 + 0.3 * lift * sin(t * 1.7);
    return float3(1.9 * cos(a), y, 1.9 * sin(a));
}

// The whole scene: the distance from p to the nearest surface.
float map(float3 p, float t, float lift, float k) {
    float d = sdSphere(p - spherePos(t, lift), 0.6);
    // The ring tilts back and forth and spins around its own axis.
    float3 q = p - ringPos(t, lift);
    q.yz = rot(q.yz, 0.45 + 0.35 * sin(t * 0.7));
    q.xz = rot(q.xz, t * 0.6);
    d = smin(d, sdTorus(q, 0.86, 0.12), k);
    // The box orbits the middle and tumbles as it goes, its corners rounded off.
    float3 b = p - boxPos(t, lift);
    b.xy = rot(b.xy, t * 0.9);
    b.yz = rot(b.yz, t * 0.7);
    d = min(d, sdBox(b, float3(0.26)) - 0.05);
    return d;
}

// The normal: how the distance changes for a small step along each axis. e.xyy is a step along x.
float3 normalAt(float3 p, float t, float lift, float k) {
    const float2 e = float2(0.001, 0.0);
    return normalize(float3(
        map(p + e.xyy, t, lift, k) - map(p - e.xyy, t, lift, k),
        map(p + e.yxy, t, lift, k) - map(p - e.yxy, t, lift, k),
        map(p + e.yyx, t, lift, k) - map(p - e.yyx, t, lift, k)));
}

// The camera circles the scene slowly, a little above it.
float3 cameraPos(float t) {
    float a = t * 0.2;
    return float3(3.9 * sin(a), 1.2, 3.9 * cos(a));
}

// 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));
}

float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
    float t = u.time * p.speed;
    float k = max(p.blend * 0.5, 0.0001);

    // A ray for this pixel, from a camera that looks at the middle of the scene.
    float side = min(u.resolution.x, u.resolution.y);
    float2 q = (uv - 0.5) * u.resolution / side * 2.0;
    float3 ro = cameraPos(t);
    float3 fw = normalize(-ro);
    float3 rt = normalize(cross(fw, float3(0.0, 1.0, 0.0)));
    float3 up = cross(rt, fw);
    float3 rd = normalize(q.x * rt + q.y * up + 2.0 * fw);

    // March: step as far as the nearest surface, again and again, until the ray touches one or leaves the scene.
    // Remember how close the ray came to anything, for the glow around the shapes.
    float dist = 0.0;
    float closest = 1e9;
    bool hit = false;
    for (int i = 0; i < 128; i++) {
        float d = map(ro + rd * dist, t, p.lift, k);
        closest = min(closest, d);
        if (d < 0.001 * dist) { hit = true; break; }
        dist += d;
        if (dist > 20.0) break;
    }

    // Behind the shapes: dark, warmer towards the bottom where the kiln is hottest, with a glow around each shape.
    float low = smoothstep(0.1, -0.8, rd.y);
    float halo = exp(-max(closest, 0.0) * 10.0);
    float level = 0.03 + 0.12 * low + 0.28 * halo;

    if (hit) {
        float3 pos = ro + rd * dist;
        float3 n = normalAt(pos, t, p.lift, k);
        // The warm light, high up, on a circle around the scene.
        float a = p.light * 6.2831853;
        float3 l = normalize(float3(cos(a), 1.1, sin(a)));
        // Diffuse: a surface is as bright as it faces the light.
        float diffuse = max(dot(n, l), 0.0);
        // The kiln's glow, rising from below onto every surface that faces down.
        float below = max(-n.y, 0.0);
        // A highlight where the surface would mirror the light into the camera.
        float3 h = normalize(l - rd);
        float spec = pow(max(dot(n, h), 0.0), p.shine) * step(0.0, dot(n, l));
        level = 0.12 + 0.55 * diffuse + 0.22 * below + 0.6 * spec;
    }

    float3 col = kiln(level + (p.heat - 0.5) * 0.5);
    return float4(col, 1.0);
}

// ---- Sound -----------------------------------------------------------------------------------------------------
// Each shape has a voice, and the voices follow the shapes. The sphere hums a low D, the ring an A above it, and the
// box an F-sharp higher still: a D major chord. A voice grows brighter as its shape floats up towards the light, the
// box is heard from where it is on the screen and louder as it swings close to the camera, and the ring shimmers as
// it melts into the sphere.

// A soft tone with some of its overtones: `bright` from 0 (a pure sine) to 1 (a reedy, fuller sound).
float voice(float ph, float bright) {
    const float tau = 6.2831853;
    return sin(tau * ph) + bright * (0.45 * sin(2.0 * tau * ph) + 0.25 * sin(3.0 * tau * ph) + 0.12 * sin(4.0 * tau * ph));
}

float2 sound(float t, constant Params& p) {
    const float tau = 6.2831853;
    float ts = t * p.speed;
    float k = max(p.blend * 0.5, 0.0001);

    // The sphere: D2 and D3, a few cents apart in each ear, brighter as it rises.
    float hs = spherePos(ts, 1.0).y / 0.22 * 0.5 + 0.5;
    float bs = 0.2 + 0.6 * hs * min(p.lift, 1.0);
    float2 low = float2(voice(73.42 * 1.001 * t, bs) + 0.6 * voice(146.83 * 0.999 * t, bs),
                        voice(73.42 * 0.999 * t, bs) + 0.6 * voice(146.83 * 1.001 * t + 0.25, bs)) * 0.1;

    // The ring: A3, swelling and shimmering as it comes close enough to the sphere to melt into it.
    float hr = ringPos(ts, 1.0).y / 0.22 * 0.5 + 0.5;
    float br = 0.2 + 0.6 * hr * min(p.lift, 1.0);
    float gap = abs(ringPos(ts, p.lift).y - spherePos(ts, p.lift).y) + 0.35 * abs(sin(ts * 0.7));
    float melt = exp(-gap * (3.0 / (0.2 + k * 4.0)));
    float shimmer = 1.0 + 0.5 * melt * sin(tau * 6.0 * t);
    float ring = voice(220.0 * t, br) * (0.35 + 0.65 * melt) * shimmer * 0.06;

    // The box: F#4, panned to its place on the screen and louder as it comes towards the camera.
    float3 ro = cameraPos(ts);
    float3 fw = normalize(-ro);
    float3 rt = normalize(cross(fw, float3(0.0, 1.0, 0.0)));
    float3 bp = boxPos(ts, p.lift);
    float3 rel = bp - ro;
    float side = clamp(dot(rel, rt) / max(dot(rel, fw), 0.5) * 0.9, -1.0, 1.0);
    float near = 1.0 / (1.0 + 0.25 * dot(rel, rel));
    float hb = 0.5 + 0.5 * sin(ts * 1.7);
    float box = voice(369.99 * t, 0.2 + 0.6 * hb * min(p.lift, 1.0)) * near * 0.16;
    float pan = 0.5 + 0.4 * side;
    float2 high = float2(1.0 - pan, pan) * box * 1.4;

    float fadeIn = smoothstep(0.0, 2.0, t);
    return tanh((low + float2(ring) + high) * 2.0) * 0.72 * 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, 0.3, 0.15, 40.0, 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(&params, 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.