Ember Tunnel
An endless winding tunnel of stone rings, flown through forever, drawn with a single ring that space is folded onto again and again; haze hides the far end, and the rings that are on fire light the stone and glow through the fog, with a sound of air rushing past each ring and a note from every burning one.
Teaches Domain repetition, fog, an endless fly-through
Builds on 03 · Floating 3D Scene (rotation, surface normals), 04 · Kaleidoscope, 05 · Cracked Clay (integer hash, sound noise)
Watch
The idea
Episode 3 marched rays through a scene of three shapes. A tunnel that never ends would need infinitely many, and a shader can't loop over infinitely many shapes. It doesn't have to.
The trick is domain repetition. Cut space into slices along the tunnel, and before asking how far a point is from the ring, move the point into the first slice. Every slice then measures the same ring, so the one ring in the code stands in all of them, as far as the ray can go. Episode 4 did the same with angles to make a kaleidoscope; here it is done with distance along a line.
Endless also means the far end has to go somewhere. Fog fades it out: the further a surface is, the more of its light is swapped for the colour of the haze. And because each slice knows its own number, each ring can be different: some of them are on fire.
It is built in five steps, and each step is a shader you can run on its own.
Stage 1 · One ring in a tube
The camera stands in the middle of a tube of radius 2 and looks down it, along z. From inside, the distance to the wall is the radius less the distance from the middle: RADIUS - length(p.xy). It is a signed distance like any other, only inside out.
The ring is episode 3's rounded box, used in a flat plane instead of in 3D: across, how far the point is from the tube's middle; along, its z. A box in that plane, swept all the way around the middle, is a square-ish band hugging the wall. The scene is the nearer of the two, min(wall, ring), and episode 3's march and normals do the rest. The only light is a lamp on the camera: a surface is as bright as it faces back along the ray, and dimmer the further away it is, so the tube fades to black down its length.
stage-1-ring.metal
// Stage 1 · One ring in a tube
// The camera stands inside a tube and looks down it. One stone ring stands a little way in.
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
constant float RADIUS = 2.0;
// The ring: episode 3's rounded box in a flat plane (across: from the middle; along: z), at z = 3.5.
float ringDist(float3 p) {
float r = length(p.xy);
float2 band = abs(float2(r - RADIUS + 0.1, p.z - 3.5)) - float2(0.28, 0.14);
return length(max(band, 0.0)) + min(max(band.x, band.y), 0.0) - 0.05;
}
// Inside out: the wall's distance is the radius less the distance from the middle.
float scene(float3 p) {
float wall = RADIUS - length(p.xy);
return min(wall, ringDist(p));
}
// Episode 3's normal: how the distance changes for a small step along each axis.
float3 normalAt(float3 p) {
const float2 e = float2(0.002, 0.0);
return normalize(float3(
scene(p + e.xyy) - scene(p - e.xyy),
scene(p + e.yxy) - scene(p - e.yxy),
scene(p + e.yyx) - scene(p - e.yyx)));
}
// 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) {
// A camera at the tube's middle, looking straight down it, along z.
float side = min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution / side * 2.0;
float3 ro = float3(0.0);
float3 rd = normalize(float3(q, 1.6));
// Episode 3's march.
float dist = 0.0;
bool hit = false;
for (int i = 0; i < 140; i++) {
float d = scene(ro + rd * dist);
if (d < 0.0015 * dist) { hit = true; break; }
dist += d;
if (dist > 60.0) break;
}
float level = 0.03;
if (hit) {
float3 n = normalAt(ro + rd * dist);
// A lamp on the camera: a surface is as bright as it faces back along the ray, dimmer the further it is.
float lamp = max(dot(n, -rd), 0.0) / (1.0 + 0.04 * dist * dist);
level = 0.03 + 0.6 * lamp;
}
return float4(kiln(level + (p.heat - 0.5) * 0.5), 1.0);
}Stage 2 · Repeat it forever
slice cuts z into slices spacing long. floor(z / spacing) is the slice's number, and z - spacing * (id + 0.5) is where the point is inside it, from -spacing/2 to spacing/2. The ring is now measured from the middle of the point's own slice instead of from z = 3.5, so every slice has one, and no loop over rings is needed.
This works because the ring fits inside its slice: the nearest ring to a point is always the one in its own slice, so the distance is still true and the march can trust it.
The camera now flies along z, three units a second at speed 1, and there are always rings ahead. Down the middle, the far rings crowd into fine lines, more of them than there are pixels to show, and they only fade because the lamp's light falls off.
stage-2-repeat.metal20 new lines since stage 1
// Stage 2 · Repeat it forever
// Cut space into slices along the tube and fold every slice onto the first: the one ring now stands in every slice.
// @param speed 0.5 0.0 2.0 How fast the camera flies down the tunnel
// @param spacing 1.5 0.7 3.0 How far apart the rings stand
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
constant float RADIUS = 2.0;
// Domain repetition: which slice of the tunnel a point is in, and where it is inside that slice. The slices are
// `spacing` long, and the local z runs from -spacing/2 to spacing/2 with the ring at 0.
float2 slice(float z, float spacing) {
float id = floor(z / spacing);
return float2(id, z - spacing * (id + 0.5));
}
// The same ring, measured from the middle of the point's own slice instead of from z = 3.5.
float ringDist(float3 p, float spacing) {
float r = length(p.xy);
float along = slice(p.z, spacing).y;
float2 band = abs(float2(r - RADIUS + 0.1, along)) - float2(0.28, 0.14);
return length(max(band, 0.0)) + min(max(band.x, band.y), 0.0) - 0.05;
}
float scene(float3 p, float spacing) {
float wall = RADIUS - length(p.xy);
return min(wall, ringDist(p, spacing));
}
// Episode 3's normal: how the distance changes for a small step along each axis.
float3 normalAt(float3 p, float spacing) {
const float2 e = float2(0.002, 0.0);
return normalize(float3(
scene(p + e.xyy, spacing) - scene(p - e.xyy, spacing),
scene(p + e.yxy, spacing) - scene(p - e.yxy, spacing),
scene(p + e.yyx, spacing) - scene(p - e.yyx, spacing)));
}
// The camera's place along the tunnel: three units a second at speed 1.
float travel(float t) {
return t * 3.0;
}
// 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;
// The camera flies along z. There are rings ahead of it however far it goes.
float side = min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution / side * 2.0;
float3 ro = float3(0.0, 0.0, travel(t));
float3 rd = normalize(float3(q, 1.6));
float dist = 0.0;
bool hit = false;
for (int i = 0; i < 140; i++) {
float d = scene(ro + rd * dist, p.spacing);
if (d < 0.0015 * dist) { hit = true; break; }
dist += d;
if (dist > 60.0) break;
}
float level = 0.03;
if (hit) {
float3 n = normalAt(ro + rd * dist, p.spacing);
float lamp = max(dot(n, -rd), 0.0) / (1.0 + 0.04 * dist * dist);
level = 0.03 + 0.6 * lamp;
}
return float4(kiln(level + (p.heat - 0.5) * 0.5), 1.0);
}11 lines from stage 1 that are gone
float ringDist(float3 p) {
⋯
float2 band = abs(float2(r - RADIUS + 0.1, p.z - 3.5)) - float2(0.28, 0.14);
⋯
float scene(float3 p) {
⋯
return min(wall, ringDist(p));
⋯
float3 normalAt(float3 p) {
⋯
scene(p + e.xyy) - scene(p - e.xyy),
scene(p + e.yxy) - scene(p - e.yxy),
scene(p + e.yyx) - scene(p - e.yyx)));
⋯
float3 ro = float3(0.0);
⋯
float d = scene(ro + rd * dist);
⋯
float3 n = normalAt(ro + rd * dist);Stage 3 · Fog
Fog swaps light for haze by distance. The share of a surface's light that gets through is exp(-dist * density): all of it close up, half of it after a set distance, half of that again after the same distance more. The rest is the haze's own colour. A ray that meets nothing is all haze.
The shimmering far end is gone, the tunnel gains depth, and fog sets how far you can see: at 0 the haze is thin and the rings go a long way, at 1 they disappear a few rings in.
stage-3-fog.metal4 new lines since stage 2
// Stage 3 · Fog
// The further away a surface is, the more of its light is swapped for the haze's own colour, so the tunnel fades away.
// @param speed 0.5 0.0 2.0 How fast the camera flies down the tunnel
// @param spacing 1.5 0.7 3.0 How far apart the rings stand
// @param fog 0.5 0.0 1.0 How thick the haze is: clear far down the tunnel, or close and smoky
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
constant float RADIUS = 2.0;
// Domain repetition: which slice of the tunnel a point is in, and where it is inside that slice. The slices are
// `spacing` long, and the local z runs from -spacing/2 to spacing/2 with the ring at 0.
float2 slice(float z, float spacing) {
float id = floor(z / spacing);
return float2(id, z - spacing * (id + 0.5));
}
// The same ring, measured from the middle of the point's own slice instead of from z = 3.5.
float ringDist(float3 p, float spacing) {
float r = length(p.xy);
float along = slice(p.z, spacing).y;
float2 band = abs(float2(r - RADIUS + 0.1, along)) - float2(0.28, 0.14);
return length(max(band, 0.0)) + min(max(band.x, band.y), 0.0) - 0.05;
}
float scene(float3 p, float spacing) {
float wall = RADIUS - length(p.xy);
return min(wall, ringDist(p, spacing));
}
// Episode 3's normal: how the distance changes for a small step along each axis.
float3 normalAt(float3 p, float spacing) {
const float2 e = float2(0.002, 0.0);
return normalize(float3(
scene(p + e.xyy, spacing) - scene(p - e.xyy, spacing),
scene(p + e.yxy, spacing) - scene(p - e.yxy, spacing),
scene(p + e.yyx, spacing) - scene(p - e.yyx, spacing)));
}
// The camera's place along the tunnel: three units a second at speed 1.
float travel(float t) {
return t * 3.0;
}
// 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;
// The camera flies along z. There are rings ahead of it however far it goes.
float side = min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution / side * 2.0;
float3 ro = float3(0.0, 0.0, travel(t));
float3 rd = normalize(float3(q, 1.6));
float dist = 0.0;
bool hit = false;
for (int i = 0; i < 140; i++) {
float d = scene(ro + rd * dist, p.spacing);
if (d < 0.0015 * dist) { hit = true; break; }
dist += d;
if (dist > 60.0) break;
}
float level = 0.03;
if (hit) {
float3 n = normalAt(ro + rd * dist, p.spacing);
float lamp = max(dot(n, -rd), 0.0) / (1.0 + 0.04 * dist * dist);
level = 0.03 + 0.6 * lamp;
}
// Fog: the share of a surface's light that gets through the haze is exp(-distance * density); the rest is the
// haze's own colour. A ray that met nothing is all haze.
float density = 0.02 + 0.2 * p.fog;
float through = hit ? exp(-dist * density) : 0.0;
float hazeLevel = 0.19 + 0.07 * p.fog;
level = mix(hazeLevel, level, through);
return float4(kiln(level + (p.heat - 0.5) * 0.5), 1.0);
}Stage 4 · A winding tunnel
A straight tube looks the same at every moment. path moves the tunnel's middle as z goes on: two slow sine waves across and one up and down, at speeds that don't line up, so the bends never quite repeat. The wall and the ring are both measured from path(p.z) instead of from the origin.
The camera rides the same line. It looks at the point on it 3 units ahead, so it turns into each bend before it gets there, and the picture leans by how far the line swings sideways ahead, like a plane banking.
Bending space this way stretches the distances a little: a point can be slightly closer to a wall than the distance says. The march takes 0.8 of each step to stay safe. curve sets how much the tunnel winds; at 0 it is the straight tube again.
stage-4-winding.metal30 new lines since stage 3
// Stage 4 · A winding tunnel
// Bend the tunnel: move its middle from side to side as z goes on. The camera rides the same line and looks ahead.
// @param speed 0.5 0.0 2.0 How fast the camera flies down the tunnel
// @param spacing 1.5 0.7 3.0 How far apart the rings stand
// @param curve 1.0 0.0 2.0 How much the tunnel winds
// @param fog 0.5 0.0 1.0 How thick the haze is: clear far down the tunnel, or close and smoky
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
constant float RADIUS = 2.0;
// Episode 3's rotation.
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);
}
// The tunnel's middle wanders as z goes on: two slow waves across, one up and down.
float2 path(float z, float curve) {
float x = 1.6 * sin(z * 0.15) + 0.8 * sin(z * 0.07 + 2.0);
float y = 0.9 * sin(z * 0.11 + 1.0);
return float2(x, y) * curve;
}
// Domain repetition: which slice of the tunnel a point is in, and where it is inside that slice. The slices are
// `spacing` long, and the local z runs from -spacing/2 to spacing/2 with the ring at 0.
float2 slice(float z, float spacing) {
float id = floor(z / spacing);
return float2(id, z - spacing * (id + 0.5));
}
// Measured from the tunnel's middle at this z, wherever it has wandered to.
float ringDist(float3 p, float spacing, float curve) {
float r = length(p.xy - path(p.z, curve));
float along = slice(p.z, spacing).y;
float2 band = abs(float2(r - RADIUS + 0.1, along)) - float2(0.28, 0.14);
return length(max(band, 0.0)) + min(max(band.x, band.y), 0.0) - 0.05;
}
float scene(float3 p, float spacing, float curve) {
float wall = RADIUS - length(p.xy - path(p.z, curve));
return min(wall, ringDist(p, spacing, curve));
}
// Episode 3's normal: how the distance changes for a small step along each axis.
float3 normalAt(float3 p, float spacing, float curve) {
const float2 e = float2(0.002, 0.0);
return normalize(float3(
scene(p + e.xyy, spacing, curve) - scene(p - e.xyy, spacing, curve),
scene(p + e.yxy, spacing, curve) - scene(p - e.yxy, spacing, curve),
scene(p + e.yyx, spacing, curve) - scene(p - e.yyx, spacing, curve)));
}
// The camera's place along the tunnel: three units a second at speed 1.
float travel(float t) {
return t * 3.0;
}
// 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;
// The camera rides the tunnel's middle line and looks at a point a little further along it, so it turns with the
// bends. It leans into them too: the picture turns by how far the line swings sideways ahead.
float side = min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution / side * 2.0;
float z = travel(t);
float3 ro = float3(path(z, p.curve), z);
float3 target = float3(path(z + 3.0, p.curve), z + 3.0);
float lean = (path(z + 2.0, p.curve).x - ro.x) * 0.12;
float3 fw = normalize(target - ro);
float3 rt = normalize(cross(float3(0.0, 1.0, 0.0), fw));
float3 up = cross(fw, rt);
q = rot(q, lean);
float3 rd = normalize(q.x * rt + q.y * up + 1.6 * fw);
// Bending the tunnel stretches its distances a little, so the march takes shorter steps.
float dist = 0.0;
bool hit = false;
for (int i = 0; i < 140; i++) {
float d = scene(ro + rd * dist, p.spacing, p.curve);
if (d < 0.0015 * dist) { hit = true; break; }
dist += d * 0.8;
if (dist > 60.0) break;
}
float level = 0.03;
if (hit) {
float3 n = normalAt(ro + rd * dist, p.spacing, p.curve);
float lamp = max(dot(n, -rd), 0.0) / (1.0 + 0.04 * dist * dist);
level = 0.03 + 0.6 * lamp;
}
float density = 0.02 + 0.2 * p.fog;
float through = hit ? exp(-dist * density) : 0.0;
float hazeLevel = 0.19 + 0.07 * p.fog;
level = mix(hazeLevel, level, through);
return float4(kiln(level + (p.heat - 0.5) * 0.5), 1.0);
}14 lines from stage 3 that are gone
float ringDist(float3 p, float spacing) {
float r = length(p.xy);
⋯
float scene(float3 p, float spacing) {
float wall = RADIUS - length(p.xy);
return min(wall, ringDist(p, spacing));
⋯
float3 normalAt(float3 p, float spacing) {
⋯
scene(p + e.xyy, spacing) - scene(p - e.xyy, spacing),
scene(p + e.yxy, spacing) - scene(p - e.yxy, spacing),
scene(p + e.yyx, spacing) - scene(p - e.yyx, spacing)));
⋯
float3 ro = float3(0.0, 0.0, travel(t));
float3 rd = normalize(float3(q, 1.6));
⋯
float d = scene(ro + rd * dist, p.spacing);
⋯
dist += d;
⋯
float3 n = normalAt(ro + rd * dist, p.spacing);Stage 5 · Ember tunnel
The slice's number, from slice, is the same for every point in it, so it can pick something for each ring. ringHeat hashes the number with episode 5's integer hash and turns the ring hot when it lands in the top share set by embers, at a random strength.
- Burning rings glow by themselves, brightest on their inner face, and flicker slowly, each at its own phase.
- They light the stone.
emberLightadds the light of the two nearest rings, each fading with distance along the tunnel, so the light changes smoothly from one slice to the next instead of jumping where they meet. - They glow through the haze. While it marches, the ray adds up how close it passes to a burning ring, weighted by the length of each step: a glow that fills the fog around each fire, as in episode 3's halo.
- The wall is stone blocks. Twelve around and two to a slice, every other row offset by half a block. The angle around the middle is repeated the way
zis, so each block has its own number too, and a hash makes some lighter and some darker. The seams between blocks glow where a fire is near.
heat slides the palette, from deep ember to white-hot.
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 * p.speed;
float spacing = p.spacing;
// The camera rides the tunnel's middle line and looks at a point a little further along it, so it turns with
// the bends, and leans into them.
float side = min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution / side * 2.0;
float z = travel(t);
float3 ro = float3(path(z, p.curve), z);
float3 target = float3(path(z + 3.0, p.curve), z + 3.0);
float lean = (path(z + 2.0, p.curve).x - ro.x) * 0.12;
float3 fw = normalize(target - ro);
float3 rt = normalize(cross(float3(0.0, 1.0, 0.0), fw));
float3 up = cross(fw, rt);
q = rot(q, lean);
float3 rd = normalize(q.x * rt + q.y * up + 1.6 * fw);
// March as in episode 3, with shorter steps, since bending the tunnel stretches its distances a little. Along
// the way, add up the light of the burning rings the ray passes near: the glow they give the haze.
float dist = 0.0;
float2 hit = float2(1e9, -1.0);
float haze = 0.0;
for (int i = 0; i < 140; i++) {
float3 pos = ro + rd * dist;
float2 s = scene(pos, spacing, p.curve);
float hot = ringHeat(slice(pos.z, spacing).x, p.embers);
haze += hot * exp(-ringDist(pos, spacing, p.curve) * 2.5) * s.x * 0.8;
if (s.x < 0.0015 * dist) { hit = s; break; }
dist += s.x * 0.8;
if (dist > 60.0) break;
}
float level = 0.0;
if (hit.y >= 0.0) {
float3 pos = ro + rd * dist;
float3 n = normalAt(pos, spacing, p.curve);
float2 xy = pos.xy - path(pos.z, p.curve);
float2 s = slice(pos.z, spacing);
float fire = emberLight(pos.z, spacing, p.embers, t);
// A lamp on the camera: a surface is as bright as it faces back along the ray, dimmer the further it is.
float lamp = max(dot(n, -rd), 0.0) / (1.0 + 0.04 * dist * dist);
// Burning rings light the stone near them, most where it faces into the tunnel.
float facing = 0.5 + 0.5 * max(dot(n, -normalize(float3(xy, 0.0))), 0.0);
level = 0.03 + 0.45 * lamp + 0.4 * fire * facing;
if (hit.y > 0.5) {
// A burning ring glows by itself, hottest on its inner face.
float hot = ringGlow(s.x, p.embers, t);
float inner = smoothstep(RADIUS - 0.25, RADIUS - 0.45, length(xy));
level += hot * (0.2 + 0.35 * inner);
} else {
// Stone blocks, twelve around and two to a slice, each a little lighter or darker. Seams glow near a fire.
float a = atan2(xy.y, xy.x) / TAU * 12.0;
float zb = pos.z / spacing * 2.0;
float row = floor(zb);
// Every other row of blocks is offset by half a block.
a += 0.5 * (row - 2.0 * floor(row * 0.5));
float col = floor(a);
uint block = uint(int(row) + 100000) * 16u + uint(int(col) + 64);
float tone = hashU(block);
// The distance to the nearest seam, across or along.
float across = min(fract(a), 1.0 - fract(a)) * 1.2;
float along = min(fract(zb), 1.0 - fract(zb)) * spacing * 0.5;
float seam = min(across, along);
level *= 0.8 + 0.35 * tone;
level += (1.0 - smoothstep(0.0, 0.04, seam)) * (0.05 + 0.5 * fire);
}
}
// Fog: the further the light comes from, the more of it is replaced by the haze's own colour, by exp(-distance).
// A ray that met nothing is all haze. The glow the ray gathered lights the haze up.
float density = 0.02 + 0.2 * p.fog;
float through = hit.y >= 0.0 ? exp(-dist * density) : 0.0;
float hazeLevel = 0.19 + 0.07 * p.fog;
level = mix(hazeLevel, level, through) + haze * (0.04 + 0.08 * p.fog);
return float4(kiln(level + (p.heat - 0.5) * 0.5), 1.0);
}episode-09-ember-tunnel.metal, the whole file116 new lines since stage 4
// Ray Kiln · Episode 9 · Ember Tunnel
//
// @episode 9 Ember Tunnel
// @length 60
// @variation 30
// @variations 1,3,4,5,6,8,11,12
// @still 20.0
// @teaches Domain repetition, fog, an endless fly-through
// @category Fragment
// @tags raymarching, domain repetition, fog, signed distance fields, hash, procedural sound
// @final Ember tunnel | Give every ring its own number, and some of them catch fire: they glow, light the walls and shine through the haze.
//
// A winding tunnel of stone rings that never ends, flown through forever. There is only one ring in the code: space
// is cut into slices and every slice is folded onto the first, so the one ring stands in all of them. Haze hides the
// far end, and the rings that are on fire light the way.
// 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 camera flies down the tunnel
// @param spacing 1.5 0.7 3.0 How far apart the rings stand
// @param curve 1.0 0.0 2.0 How much the tunnel winds
// @param fog 0.5 0.0 1.0 How thick the haze is: clear far down the tunnel, or close and smoky
// @param embers 0.35 0.0 1.0 How many of the rings are on fire
// @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;
constant float RADIUS = 2.0;
// Episode 5's integer hash: a whole number in, a random number from 0 to 1 out.
float hashU(uint n) {
n = (n << 13u) ^ n;
n = n * (n * n * 15731u + 789221u) + 1376312589u;
return float(n & 0x7fffffffu) / float(0x7fffffff);
}
// Episode 3's rotation.
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);
}
// ---- The tunnel ------------------------------------------------------------------------------------------------
// The tunnel runs along z. Its middle wanders as z goes on: two slow waves across, one up and down.
float2 path(float z, float curve) {
float x = 1.6 * sin(z * 0.15) + 0.8 * sin(z * 0.07 + 2.0);
float y = 0.9 * sin(z * 0.11 + 1.0);
return float2(x, y) * curve;
}
// Domain repetition: which slice of the tunnel a point is in, and where it is inside that slice. The slices are
// `spacing` long, and the local z runs from -spacing/2 to spacing/2 with the ring at 0.
float2 slice(float z, float spacing) {
float id = floor(z / spacing);
return float2(id, z - spacing * (id + 0.5));
}
// How hot a ring is, from 0 to 1, picked by its number. `embers` is the share of rings on fire.
float ringHeat(float id, float embers) {
uint n = uint(int(id) + 100000);
float pick = hashU(n * 3u);
float strength = 0.65 + 0.35 * hashU(n * 3u + 1u);
return smoothstep(1.0 - embers, 1.0 - embers + 0.12, pick) * strength;
}
// Its glow right now: the heat, flickering, each ring at its own phase.
float ringGlow(float id, float embers, float t) {
return ringHeat(id, embers) * (0.8 + 0.2 * sin(t * 3.0 + id * 1.7));
}
// The ring: episode 3's rounded box, in a flat plane: across is the distance from the tunnel's middle, along is
// the local z of the point's own slice.
float ringDist(float3 p, float spacing, float curve) {
float r = length(p.xy - path(p.z, curve));
float along = slice(p.z, spacing).y;
float2 band = abs(float2(r - RADIUS + 0.1, along)) - float2(0.28, 0.14);
return length(max(band, 0.0)) + min(max(band.x, band.y), 0.0) - 0.05;
}
// The stone: the tube's wall and the ring. y says which is nearer: 0 for the wall, 1 for the ring.
float2 scene(float3 p, float spacing, float curve) {
float wall = RADIUS - length(p.xy - path(p.z, curve));
float ring = ringDist(p, spacing, curve);
return ring < wall ? float2(ring, 1.0) : float2(wall, 0.0);
}
// Episode 3's normal: how the distance changes for a small step along each axis.
float3 normalAt(float3 p, float sp, float cv) {
const float2 e = float2(0.002, 0.0);
return normalize(float3(
scene(p + e.xyy, sp, cv).x - scene(p - e.xyy, sp, cv).x,
scene(p + e.yxy, sp, cv).x - scene(p - e.yxy, sp, cv).x,
scene(p + e.yyx, sp, cv).x - scene(p - e.yyx, sp, cv).x));
}
// The light of the two nearest burning rings at z, each fading with distance, so it changes smoothly.
float emberLight(float z, float spacing, float embers, float t) {
float g = z / spacing - 0.5;
float j = floor(g);
float f = g - j;
float a = ringGlow(j, embers, t);
float b = ringGlow(j + 1.0, embers, t);
return a * exp(-f * spacing * 1.6) + b * exp(-(1.0 - f) * spacing * 1.6);
}
// The camera's place along the tunnel: three units a second at speed 1.
float travel(float t) {
return t * 3.0;
}
// ---- 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 spacing = p.spacing;
// The camera rides the tunnel's middle line and looks at a point a little further along it, so it turns with
// the bends, and leans into them.
float side = min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution / side * 2.0;
float z = travel(t);
float3 ro = float3(path(z, p.curve), z);
float3 target = float3(path(z + 3.0, p.curve), z + 3.0);
float lean = (path(z + 2.0, p.curve).x - ro.x) * 0.12;
float3 fw = normalize(target - ro);
float3 rt = normalize(cross(float3(0.0, 1.0, 0.0), fw));
float3 up = cross(fw, rt);
q = rot(q, lean);
float3 rd = normalize(q.x * rt + q.y * up + 1.6 * fw);
// March as in episode 3, with shorter steps, since bending the tunnel stretches its distances a little. Along
// the way, add up the light of the burning rings the ray passes near: the glow they give the haze.
float dist = 0.0;
float2 hit = float2(1e9, -1.0);
float haze = 0.0;
for (int i = 0; i < 140; i++) {
float3 pos = ro + rd * dist;
float2 s = scene(pos, spacing, p.curve);
float hot = ringHeat(slice(pos.z, spacing).x, p.embers);
haze += hot * exp(-ringDist(pos, spacing, p.curve) * 2.5) * s.x * 0.8;
if (s.x < 0.0015 * dist) { hit = s; break; }
dist += s.x * 0.8;
if (dist > 60.0) break;
}
float level = 0.0;
if (hit.y >= 0.0) {
float3 pos = ro + rd * dist;
float3 n = normalAt(pos, spacing, p.curve);
float2 xy = pos.xy - path(pos.z, p.curve);
float2 s = slice(pos.z, spacing);
float fire = emberLight(pos.z, spacing, p.embers, t);
// A lamp on the camera: a surface is as bright as it faces back along the ray, dimmer the further it is.
float lamp = max(dot(n, -rd), 0.0) / (1.0 + 0.04 * dist * dist);
// Burning rings light the stone near them, most where it faces into the tunnel.
float facing = 0.5 + 0.5 * max(dot(n, -normalize(float3(xy, 0.0))), 0.0);
level = 0.03 + 0.45 * lamp + 0.4 * fire * facing;
if (hit.y > 0.5) {
// A burning ring glows by itself, hottest on its inner face.
float hot = ringGlow(s.x, p.embers, t);
float inner = smoothstep(RADIUS - 0.25, RADIUS - 0.45, length(xy));
level += hot * (0.2 + 0.35 * inner);
} else {
// Stone blocks, twelve around and two to a slice, each a little lighter or darker. Seams glow near a fire.
float a = atan2(xy.y, xy.x) / TAU * 12.0;
float zb = pos.z / spacing * 2.0;
float row = floor(zb);
// Every other row of blocks is offset by half a block.
a += 0.5 * (row - 2.0 * floor(row * 0.5));
float col = floor(a);
uint block = uint(int(row) + 100000) * 16u + uint(int(col) + 64);
float tone = hashU(block);
// The distance to the nearest seam, across or along.
float across = min(fract(a), 1.0 - fract(a)) * 1.2;
float along = min(fract(zb), 1.0 - fract(zb)) * spacing * 0.5;
float seam = min(across, along);
level *= 0.8 + 0.35 * tone;
level += (1.0 - smoothstep(0.0, 0.04, seam)) * (0.05 + 0.5 * fire);
}
}
// Fog: the further the light comes from, the more of it is replaced by the haze's own colour, by exp(-distance).
// A ray that met nothing is all haze. The glow the ray gathered lights the haze up.
float density = 0.02 + 0.2 * p.fog;
float through = hit.y >= 0.0 ? exp(-dist * density) : 0.0;
float hazeLevel = 0.19 + 0.07 * p.fog;
level = mix(hazeLevel, level, through) + haze * (0.04 + 0.08 * p.fog);
return float4(kiln(level + (p.heat - 0.5) * 0.5), 1.0);
}
// ---- Sound -----------------------------------------------------------------------------------------------------
// Flying through the tunnel: a rush of air as each ring sweeps past, and every ring on fire sings a note, rising as
// it comes and fading as it falls behind. Under it the tunnel hums, and the hum leans into the bends with the camera.
// Episode 5's sound noise: random values joined `rate` times a second. A lower rate sounds darker.
float noise1(float t, float rate, uint seed) {
float x = t * rate;
float i = floor(x), f = fract(x);
f = f * f * (3.0 - 2.0 * f);
uint k = uint(i) + seed * 7919u;
return mix(hashU(k), hashU(k + 1u), f) * 2.0 - 1.0;
}
// The notes the burning rings sing: A minor pentatonic, from A3 up, picked by the ring's number.
float ringNote(float id) {
uint n = uint(int(id) + 100000);
float k = floor(hashU(n * 3u + 2u) * 6.999);
// The steps of the scale in semitones: 0, 3, 5, 7, 10, then the same an octave up.
float semis = k < 1.0 ? 0.0 : k < 2.0 ? 3.0 : k < 3.0 ? 5.0 : k < 4.0 ? 7.0
: k < 5.0 ? 10.0 : k < 6.0 ? 12.0 : 15.0;
return 220.0 * exp2(semis / 12.0);
}
float2 sound(float t, constant Params& p) {
float ts = t * p.speed;
float z = travel(ts);
float spacing = p.spacing;
// The rings near the camera, from a few behind to a few ahead. The ring with number j stands at (j + 0.5) * spacing.
float2 air = float2(0.0);
float2 song = float2(0.0);
float base = floor(z / spacing - 0.5);
for (int k = -4; k <= 5; k++) {
float j = base + float(k);
// Negative while the ring is ahead, positive once it is behind.
float dz = z - (j + 0.5) * spacing;
// The rush: a burst of noise as the ring passes, swelling and falling away smoothly with its distance.
float pass = exp(-dz * dz * 6.0);
uint s = uint(int(j) + 100000);
float2 rush = float2(noise1(t, 700.0, s * 2u), noise1(t, 700.0, s * 2u + 1u));
air += rush * pass;
// The song: a burning ring's note, louder as it comes, gone 3.5 units behind or ahead.
float hot = ringHeat(j, p.embers);
float near = max(1.0 - abs(dz) / 3.5, 0.0);
near *= near;
float f = ringNote(j);
float tone = sin(TAU * f * t) + 0.25 * sin(TAU * 2.0 * f * t)
+ 0.1 * sin(TAU * 3.0 * f * t);
// Ahead, a ring sounds a little more from one side, behind from the other, so they seem to fly past.
float pan = 0.5 + 0.3 * tanh(dz) * (hashU(s * 3u + 3u) > 0.5 ? 1.0 : -1.0);
song += float2(1.0 - pan, pan) * tone * hot * near;
}
air *= 0.4 * min(p.speed * 2.0, 1.0);
song *= 0.32;
// The hum: A1 and E2, a few cents apart in each ear. Where the tunnel bends, it leans to the side the camera turns.
float ahead = path(z + 2.0, p.curve).x - path(z, p.curve).x;
float turn = clamp(ahead * 0.5, -0.6, 0.6);
float humL = sin(TAU * 55.0 * 1.002 * t) + 0.5 * sin(TAU * 82.41 * t);
float humR = sin(TAU * 55.0 * 0.998 * t + 0.5) + 0.5 * sin(TAU * 82.41 * t + 0.9);
float2 hum = float2(humL, humR);
hum *= float2(1.0 - turn * 0.5, 1.0 + turn * 0.5) * 0.07;
float fadeIn = smoothstep(0.0, 2.0, t);
return tanh((air + song + hum) * 1.8) * 0.6 * fadeIn * p.volume;
}16 lines from stage 4 that are gone
float scene(float3 p, float spacing, float curve) {
⋯
return min(wall, ringDist(p, spacing, curve));
⋯
float3 normalAt(float3 p, float spacing, float curve) {
⋯
scene(p + e.xyy, spacing, curve) - scene(p - e.xyy, spacing, curve),
scene(p + e.yxy, spacing, curve) - scene(p - e.yxy, spacing, curve),
scene(p + e.yyx, spacing, curve) - scene(p - e.yyx, spacing, curve)));
⋯
bool hit = false;
⋯
float d = scene(ro + rd * dist, p.spacing, p.curve);
if (d < 0.0015 * dist) { hit = true; break; }
dist += d * 0.8;
⋯
float level = 0.03;
if (hit) {
float3 n = normalAt(ro + rd * dist, p.spacing, p.curve);
⋯
level = 0.03 + 0.6 * lamp;
⋯
float through = hit ? exp(-dist * density) : 0.0;
⋯
level = mix(hazeLevel, level, through);The sound
The sound follows the camera through the same tunnel, using the same travel, slice numbers and ringHeat.
- The rush. The ring with number
jstands at(j + 0.5) * spacing. For the ten rings around the camera, the sound works out how far each is ahead or behind, and adds a burst of noise, episode 5's sound noise at 700 values a second, shaped by a bell curve of that distance: it swells as the ring comes and falls away as it passes. At speed 0 there is no air. - The song. Every ring on fire sings its own note, picked from its number in A minor pentatonic, from A3 up an octave and a third. It grows louder as the ring comes within 3.5 units and fades as it falls behind, and leans from one side to the other as it passes, so the rings seem to fly by. With
embersat 0 the tunnel is silent but for the air and the hum. - The hum. A low A at 55 Hz, a few cents apart in each ear, and the E above it. Where the tunnel bends, it leans towards the side the camera turns.
Everything goes through tanh to round off the loud moments and fades in over the first two seconds. The levels at the defaults are a peak of about -8 dBFS and an average (RMS) of about -19 dBFS.
Try this
- Set
fogto 0, then to 1, and watch how far down the tunnel you can see. - Set
embersto 1 to set every ring on fire, or to 0 for a cold, dark tunnel lit only by the lamp. - Set
spacingto 0.7 for a tight tunnel of ribs, or to 3 for rings far apart. - Set
curveto 2 for a tunnel that twists hard, then to 0 for a straight one. - In
slice, change the ring's localztoz - spacing * (id + 0.5) + 0.12 * sin(id)and each ring shifts a little from its place: the number can move the copies, not just colour them. Keep the shift small, so each ring stays inside its own slice even at aspacingof 0.7.
Parameters
Pick a parameter and drag the slider. Each frame is the same moment of the shader, with only that one setting changed.
| Parameter | Default | Range | What it does |
|---|---|---|---|
speed | 0.5 | 0.0 to 2.0 | How fast the camera flies down the tunnel |
spacing | 1.5 | 0.7 to 3.0 | How far apart the rings stand |
curve | 1.0 | 0.0 to 2.0 | How much the tunnel winds |
fog | 0.5 | 0.0 to 1.0 | How thick the haze is: clear far down the tunnel, or close and smoky |
embers | 0.35 | 0.0 to 1.0 | How many of the rings are on fire |
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-09-ember-tunnel.metal
// Ray Kiln · Episode 9 · Ember Tunnel
//
// @episode 9 Ember Tunnel
// @length 60
// @variation 30
// @variations 1,3,4,5,6,8,11,12
// @still 20.0
// @teaches Domain repetition, fog, an endless fly-through
// @category Fragment
// @tags raymarching, domain repetition, fog, signed distance fields, hash, procedural sound
// @final Ember tunnel | Give every ring its own number, and some of them catch fire: they glow, light the walls and shine through the haze.
//
// A winding tunnel of stone rings that never ends, flown through forever. There is only one ring in the code: space
// is cut into slices and every slice is folded onto the first, so the one ring stands in all of them. Haze hides the
// far end, and the rings that are on fire light the way.
// 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 camera flies down the tunnel
// @param spacing 1.5 0.7 3.0 How far apart the rings stand
// @param curve 1.0 0.0 2.0 How much the tunnel winds
// @param fog 0.5 0.0 1.0 How thick the haze is: clear far down the tunnel, or close and smoky
// @param embers 0.35 0.0 1.0 How many of the rings are on fire
// @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;
constant float RADIUS = 2.0;
// Episode 5's integer hash: a whole number in, a random number from 0 to 1 out.
float hashU(uint n) {
n = (n << 13u) ^ n;
n = n * (n * n * 15731u + 789221u) + 1376312589u;
return float(n & 0x7fffffffu) / float(0x7fffffff);
}
// Episode 3's rotation.
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);
}
// ---- The tunnel ------------------------------------------------------------------------------------------------
// The tunnel runs along z. Its middle wanders as z goes on: two slow waves across, one up and down.
float2 path(float z, float curve) {
float x = 1.6 * sin(z * 0.15) + 0.8 * sin(z * 0.07 + 2.0);
float y = 0.9 * sin(z * 0.11 + 1.0);
return float2(x, y) * curve;
}
// Domain repetition: which slice of the tunnel a point is in, and where it is inside that slice. The slices are
// `spacing` long, and the local z runs from -spacing/2 to spacing/2 with the ring at 0.
float2 slice(float z, float spacing) {
float id = floor(z / spacing);
return float2(id, z - spacing * (id + 0.5));
}
// How hot a ring is, from 0 to 1, picked by its number. `embers` is the share of rings on fire.
float ringHeat(float id, float embers) {
uint n = uint(int(id) + 100000);
float pick = hashU(n * 3u);
float strength = 0.65 + 0.35 * hashU(n * 3u + 1u);
return smoothstep(1.0 - embers, 1.0 - embers + 0.12, pick) * strength;
}
// Its glow right now: the heat, flickering, each ring at its own phase.
float ringGlow(float id, float embers, float t) {
return ringHeat(id, embers) * (0.8 + 0.2 * sin(t * 3.0 + id * 1.7));
}
// The ring: episode 3's rounded box, in a flat plane: across is the distance from the tunnel's middle, along is
// the local z of the point's own slice.
float ringDist(float3 p, float spacing, float curve) {
float r = length(p.xy - path(p.z, curve));
float along = slice(p.z, spacing).y;
float2 band = abs(float2(r - RADIUS + 0.1, along)) - float2(0.28, 0.14);
return length(max(band, 0.0)) + min(max(band.x, band.y), 0.0) - 0.05;
}
// The stone: the tube's wall and the ring. y says which is nearer: 0 for the wall, 1 for the ring.
float2 scene(float3 p, float spacing, float curve) {
float wall = RADIUS - length(p.xy - path(p.z, curve));
float ring = ringDist(p, spacing, curve);
return ring < wall ? float2(ring, 1.0) : float2(wall, 0.0);
}
// Episode 3's normal: how the distance changes for a small step along each axis.
float3 normalAt(float3 p, float sp, float cv) {
const float2 e = float2(0.002, 0.0);
return normalize(float3(
scene(p + e.xyy, sp, cv).x - scene(p - e.xyy, sp, cv).x,
scene(p + e.yxy, sp, cv).x - scene(p - e.yxy, sp, cv).x,
scene(p + e.yyx, sp, cv).x - scene(p - e.yyx, sp, cv).x));
}
// The light of the two nearest burning rings at z, each fading with distance, so it changes smoothly.
float emberLight(float z, float spacing, float embers, float t) {
float g = z / spacing - 0.5;
float j = floor(g);
float f = g - j;
float a = ringGlow(j, embers, t);
float b = ringGlow(j + 1.0, embers, t);
return a * exp(-f * spacing * 1.6) + b * exp(-(1.0 - f) * spacing * 1.6);
}
// The camera's place along the tunnel: three units a second at speed 1.
float travel(float t) {
return t * 3.0;
}
// ---- 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 spacing = p.spacing;
// The camera rides the tunnel's middle line and looks at a point a little further along it, so it turns with
// the bends, and leans into them.
float side = min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution / side * 2.0;
float z = travel(t);
float3 ro = float3(path(z, p.curve), z);
float3 target = float3(path(z + 3.0, p.curve), z + 3.0);
float lean = (path(z + 2.0, p.curve).x - ro.x) * 0.12;
float3 fw = normalize(target - ro);
float3 rt = normalize(cross(float3(0.0, 1.0, 0.0), fw));
float3 up = cross(fw, rt);
q = rot(q, lean);
float3 rd = normalize(q.x * rt + q.y * up + 1.6 * fw);
// March as in episode 3, with shorter steps, since bending the tunnel stretches its distances a little. Along
// the way, add up the light of the burning rings the ray passes near: the glow they give the haze.
float dist = 0.0;
float2 hit = float2(1e9, -1.0);
float haze = 0.0;
for (int i = 0; i < 140; i++) {
float3 pos = ro + rd * dist;
float2 s = scene(pos, spacing, p.curve);
float hot = ringHeat(slice(pos.z, spacing).x, p.embers);
haze += hot * exp(-ringDist(pos, spacing, p.curve) * 2.5) * s.x * 0.8;
if (s.x < 0.0015 * dist) { hit = s; break; }
dist += s.x * 0.8;
if (dist > 60.0) break;
}
float level = 0.0;
if (hit.y >= 0.0) {
float3 pos = ro + rd * dist;
float3 n = normalAt(pos, spacing, p.curve);
float2 xy = pos.xy - path(pos.z, p.curve);
float2 s = slice(pos.z, spacing);
float fire = emberLight(pos.z, spacing, p.embers, t);
// A lamp on the camera: a surface is as bright as it faces back along the ray, dimmer the further it is.
float lamp = max(dot(n, -rd), 0.0) / (1.0 + 0.04 * dist * dist);
// Burning rings light the stone near them, most where it faces into the tunnel.
float facing = 0.5 + 0.5 * max(dot(n, -normalize(float3(xy, 0.0))), 0.0);
level = 0.03 + 0.45 * lamp + 0.4 * fire * facing;
if (hit.y > 0.5) {
// A burning ring glows by itself, hottest on its inner face.
float hot = ringGlow(s.x, p.embers, t);
float inner = smoothstep(RADIUS - 0.25, RADIUS - 0.45, length(xy));
level += hot * (0.2 + 0.35 * inner);
} else {
// Stone blocks, twelve around and two to a slice, each a little lighter or darker. Seams glow near a fire.
float a = atan2(xy.y, xy.x) / TAU * 12.0;
float zb = pos.z / spacing * 2.0;
float row = floor(zb);
// Every other row of blocks is offset by half a block.
a += 0.5 * (row - 2.0 * floor(row * 0.5));
float col = floor(a);
uint block = uint(int(row) + 100000) * 16u + uint(int(col) + 64);
float tone = hashU(block);
// The distance to the nearest seam, across or along.
float across = min(fract(a), 1.0 - fract(a)) * 1.2;
float along = min(fract(zb), 1.0 - fract(zb)) * spacing * 0.5;
float seam = min(across, along);
level *= 0.8 + 0.35 * tone;
level += (1.0 - smoothstep(0.0, 0.04, seam)) * (0.05 + 0.5 * fire);
}
}
// Fog: the further the light comes from, the more of it is replaced by the haze's own colour, by exp(-distance).
// A ray that met nothing is all haze. The glow the ray gathered lights the haze up.
float density = 0.02 + 0.2 * p.fog;
float through = hit.y >= 0.0 ? exp(-dist * density) : 0.0;
float hazeLevel = 0.19 + 0.07 * p.fog;
level = mix(hazeLevel, level, through) + haze * (0.04 + 0.08 * p.fog);
return float4(kiln(level + (p.heat - 0.5) * 0.5), 1.0);
}
// ---- Sound -----------------------------------------------------------------------------------------------------
// Flying through the tunnel: a rush of air as each ring sweeps past, and every ring on fire sings a note, rising as
// it comes and fading as it falls behind. Under it the tunnel hums, and the hum leans into the bends with the camera.
// Episode 5's sound noise: random values joined `rate` times a second. A lower rate sounds darker.
float noise1(float t, float rate, uint seed) {
float x = t * rate;
float i = floor(x), f = fract(x);
f = f * f * (3.0 - 2.0 * f);
uint k = uint(i) + seed * 7919u;
return mix(hashU(k), hashU(k + 1u), f) * 2.0 - 1.0;
}
// The notes the burning rings sing: A minor pentatonic, from A3 up, picked by the ring's number.
float ringNote(float id) {
uint n = uint(int(id) + 100000);
float k = floor(hashU(n * 3u + 2u) * 6.999);
// The steps of the scale in semitones: 0, 3, 5, 7, 10, then the same an octave up.
float semis = k < 1.0 ? 0.0 : k < 2.0 ? 3.0 : k < 3.0 ? 5.0 : k < 4.0 ? 7.0
: k < 5.0 ? 10.0 : k < 6.0 ? 12.0 : 15.0;
return 220.0 * exp2(semis / 12.0);
}
float2 sound(float t, constant Params& p) {
float ts = t * p.speed;
float z = travel(ts);
float spacing = p.spacing;
// The rings near the camera, from a few behind to a few ahead. The ring with number j stands at (j + 0.5) * spacing.
float2 air = float2(0.0);
float2 song = float2(0.0);
float base = floor(z / spacing - 0.5);
for (int k = -4; k <= 5; k++) {
float j = base + float(k);
// Negative while the ring is ahead, positive once it is behind.
float dz = z - (j + 0.5) * spacing;
// The rush: a burst of noise as the ring passes, swelling and falling away smoothly with its distance.
float pass = exp(-dz * dz * 6.0);
uint s = uint(int(j) + 100000);
float2 rush = float2(noise1(t, 700.0, s * 2u), noise1(t, 700.0, s * 2u + 1u));
air += rush * pass;
// The song: a burning ring's note, louder as it comes, gone 3.5 units behind or ahead.
float hot = ringHeat(j, p.embers);
float near = max(1.0 - abs(dz) / 3.5, 0.0);
near *= near;
float f = ringNote(j);
float tone = sin(TAU * f * t) + 0.25 * sin(TAU * 2.0 * f * t)
+ 0.1 * sin(TAU * 3.0 * f * t);
// Ahead, a ring sounds a little more from one side, behind from the other, so they seem to fly past.
float pan = 0.5 + 0.3 * tanh(dz) * (hashU(s * 3u + 3u) > 0.5 ? 1.0 : -1.0);
song += float2(1.0 - pan, pan) * tone * hot * near;
}
air *= 0.4 * min(p.speed * 2.0, 1.0);
song *= 0.32;
// The hum: A1 and E2, a few cents apart in each ear. Where the tunnel bends, it leans to the side the camera turns.
float ahead = path(z + 2.0, p.curve).x - path(z, p.curve).x;
float turn = clamp(ahead * 0.5, -0.6, 0.6);
float humL = sin(TAU * 55.0 * 1.002 * t) + 0.5 * sin(TAU * 82.41 * t);
float humR = sin(TAU * 55.0 * 0.998 * t + 0.5) + 0.5 * sin(TAU * 82.41 * t + 0.9);
float2 hum = float2(humL, humR);
hum *= float2(1.0 - turn * 0.5, 1.0 + turn * 0.5) * 0.07;
float fadeIn = smoothstep(0.0, 2.0, t);
return tanh((air + song + hum) * 1.8) * 0.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, 1.5, 1.0, 0.5, 0.35, 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 3D Scenes
Press ? for keyboard shortcuts.


