Glass Orb
An orb of glass on a floor of kiln tiles, with an ember tumbling inside it. Where a ray meets the glass it splits in two, one part reflected like a mirror and the rest bent into the glass, and Fresnel decides how much goes each way. Inside, the bent ray is marched again until it finds the ember or the far wall, where it bends out and sees the room upside down.
Teaches Refraction, reflection, Fresnel
Builds on 03 · Floating 3D Scene (torus distance, rotation), 05 · Cracked Clay (integer hash)
Watch
The idea
Episode 3 marched rays to shapes and lit them like clay: the light stopped at the surface. Glass doesn't stop it. Where a ray meets glass, two things happen at once. Some of the light reflects: it bounces off as off a mirror and goes on to see the room. The rest refracts: it goes into the glass and bends, because light is slower in glass than in air. It crosses the glass and bends again on the way out.
How much goes each way is the Fresnel effect, and you have seen it on every window and every lake: looking straight at the water you see into it, looking across it at a low angle you see the sky reflected. A shader can do all of this with two built-in functions, reflect and refract, and one more march: once the ray is inside the orb, it is marched again, from the inside, to find what is in there and where it leaves.
It is built in four steps, and each step is a shader you can run on its own.
Stage 1 · A ball in a kiln
The room comes first, because glass shows nothing but what is around it. env(ro, rd) is everything a ray sees once it is free of the orb: if it points down, the floor, found without marching, since a flat floor at y = -1 is met at the distance (FLOOR - ro.y) / rd.y. The floor is clay tiles, each a slightly different shade (a hash of the tile's number), with dark grout, and the orb's shadow cast along the light. Far tiles fade into the kiln's haze by exp(-d * 0.12), so the floor meets the dark without a line. A ray pointing up sees the kiln: dark, a little warmer toward the horizon, and glowing toward the light (pow(dot(rd, l), 6)).
The ball is episode 3's march: orb(p) is the distance to a sphere of radius 1, and the ray steps by that distance until it touches. Here it is lit like clay, as bright as its surface faces the light.
stage-1-ball.metal
// Stage 1 · A ball in a kiln
// The room first: a floor of clay tiles fading into the dark of the kiln, and a ball resting on it, found by
// marching a ray to it as in episode 3 and lit like clay: bright where it faces the light, dark where it turns away.
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
// 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));
}
constant float FLOOR = -1.0;
// Where the light comes from: high, behind and to the left.
float3 lightDir() {
return normalize(float3(-0.6, 1.0, -0.4));
}
// The floor: tiles of fired clay with dark grout, and the orb's shadow cast
// along the light.
float floorLevel(float3 p, float3 l) {
float2 g = p.xz * 1.6;
float2 f = abs(fract(g) - 0.5);
float grout = smoothstep(0.47, 0.44, max(f.x, f.y));
float2 id = floor(g);
float tone = 0.28 + 0.08 * fract(sin(dot(id, float2(12.9, 78.2))) * 437.5);
float3 toOrb = -p;
float along = dot(toOrb, l);
float miss = length(toOrb - l * along);
float shadow = along > 0.0 ? smoothstep(0.55, 1.15, miss) : 1.0;
return tone * grout * (0.35 + 0.65 * shadow);
}
// The dark of the kiln, warmer toward the horizon, its hot mouth glowing
// where the light comes from.
float sky(float3 rd, float3 l) {
float up = abs(rd.y);
float mouth = pow(max(dot(rd, l), 0.0), 6.0);
return 0.08 + 0.14 * exp(-up * 6.0) + 0.55 * mouth;
}
// What a ray sees of the room: the floor if it heads down, fading into the
// haze of the far kiln, otherwise the kiln's dark.
float env(float3 ro, float3 rd) {
float3 l = lightDir();
float back = sky(rd, l);
if (rd.y < -0.001) {
float d = (FLOOR - ro.y) / rd.y;
if (d > 0.0) {
float3 p = ro + rd * d;
return mix(back, floorLevel(p, l), exp(-d * 0.12));
}
}
return back;
}
// The camera circles the orb, rising and falling a little.
float3 cameraPos(float t) {
float a = t * 0.25;
return float3(3.6 * sin(a), 0.55 + 0.35 * sin(t * 0.21), 3.6 * cos(a));
}
// The orb: a sphere of radius 1, resting on the floor.
float orb(float3 p) {
return length(p) - 1.0;
}
float3 orbNormal(float3 p) {
const float2 e = float2(0.001, 0.0);
return normalize(float3(
orb(p + e.xyy) - orb(p - e.xyy),
orb(p + e.yxy) - orb(p - e.yxy),
orb(p + e.yyx) - orb(p - e.yyx)));
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float t = u.time * 0.5;
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(float3(0.0, -0.15, 0.0) - 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.2 * fw);
// March to the orb: step by the distance to it until the ray touches it.
float dist = 0.0;
bool hit = false;
for (int i = 0; i < 128; i++) {
float d = orb(ro + rd * dist);
if (d < 0.001) { hit = true; break; }
dist += d;
if (dist > 12.0) break;
}
float shift = (p.heat - 0.5) * 0.4;
if (!hit) return float4(kiln(env(ro, rd) + shift), 1.0);
float3 pos = ro + rd * dist;
float3 n = orbNormal(pos);
// Clay: as bright as the surface faces the light.
float lit = max(dot(n, lightDir()), 0.0);
return float4(kiln(0.12 + 0.5 * lit + shift), 1.0);
}Stage 2 · A mirror
reflect(rd, n) turns the ray about the surface's normal, the way a ball bounces off a wall: the part of the ray along the normal is flipped, the rest kept. The new ray starts a hair outside the surface (n * 0.004), so it doesn't meet the ball it just left, and asks env what it sees. The bottom half of the ball shows the floor curving around it; the top shows the dark of the kiln, with the glow of the light in it.
stage-2-mirror.metal3 new lines since stage 1
// Stage 2 · A mirror
// Make the ball a mirror. Where the ray meets it, reflect turns the ray about the surface's normal, the way a ball
// bounces off a wall, and the new ray goes on to see the room: the floor curving round the ball, the kiln above.
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
// 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));
}
constant float FLOOR = -1.0;
// Where the light comes from: high, behind and to the left.
float3 lightDir() {
return normalize(float3(-0.6, 1.0, -0.4));
}
// The floor: tiles of fired clay with dark grout, and the orb's shadow cast
// along the light.
float floorLevel(float3 p, float3 l) {
float2 g = p.xz * 1.6;
float2 f = abs(fract(g) - 0.5);
float grout = smoothstep(0.47, 0.44, max(f.x, f.y));
float2 id = floor(g);
float tone = 0.28 + 0.08 * fract(sin(dot(id, float2(12.9, 78.2))) * 437.5);
float3 toOrb = -p;
float along = dot(toOrb, l);
float miss = length(toOrb - l * along);
float shadow = along > 0.0 ? smoothstep(0.55, 1.15, miss) : 1.0;
return tone * grout * (0.35 + 0.65 * shadow);
}
// The dark of the kiln, warmer toward the horizon, its hot mouth glowing
// where the light comes from.
float sky(float3 rd, float3 l) {
float up = abs(rd.y);
float mouth = pow(max(dot(rd, l), 0.0), 6.0);
return 0.08 + 0.14 * exp(-up * 6.0) + 0.55 * mouth;
}
// What a ray sees of the room: the floor if it heads down, fading into the
// haze of the far kiln, otherwise the kiln's dark.
float env(float3 ro, float3 rd) {
float3 l = lightDir();
float back = sky(rd, l);
if (rd.y < -0.001) {
float d = (FLOOR - ro.y) / rd.y;
if (d > 0.0) {
float3 p = ro + rd * d;
return mix(back, floorLevel(p, l), exp(-d * 0.12));
}
}
return back;
}
// The camera circles the orb, rising and falling a little.
float3 cameraPos(float t) {
float a = t * 0.25;
return float3(3.6 * sin(a), 0.55 + 0.35 * sin(t * 0.21), 3.6 * cos(a));
}
// The orb: a sphere of radius 1, resting on the floor.
float orb(float3 p) {
return length(p) - 1.0;
}
float3 orbNormal(float3 p) {
const float2 e = float2(0.001, 0.0);
return normalize(float3(
orb(p + e.xyy) - orb(p - e.xyy),
orb(p + e.yxy) - orb(p - e.yxy),
orb(p + e.yyx) - orb(p - e.yyx)));
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float t = u.time * 0.5;
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(float3(0.0, -0.15, 0.0) - 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.2 * fw);
// March to the orb: step by the distance to it until the ray touches it.
float dist = 0.0;
bool hit = false;
for (int i = 0; i < 128; i++) {
float d = orb(ro + rd * dist);
if (d < 0.001) { hit = true; break; }
dist += d;
if (dist > 12.0) break;
}
float shift = (p.heat - 0.5) * 0.4;
if (!hit) return float4(kiln(env(ro, rd) + shift), 1.0);
float3 pos = ro + rd * dist;
float3 n = orbNormal(pos);
// Bounce the ray off the surface and look where it goes. Start it a hair
// outside, so it doesn't meet the ball it just left.
float3 rr = reflect(rd, n);
float mirror = env(pos + n * 0.004, rr);
return float4(kiln(mirror + shift), 1.0);
}2 lines from stage 1 that are gone
float lit = max(dot(n, lightDir()), 0.0);
return float4(kiln(0.12 + 0.5 * lit + shift), 1.0);Stage 3 · Bent light
refract(rd, n, eta) bends a ray as it crosses into something where light goes at a different speed. eta is the ratio of the two indices of refraction: going from air (1) into glass (ior, 1.5) it is 1 / ior, and the ray bends toward the normal.
Then inside marches the ray again, from inside the glass. The distance to the wall from inside is -orb(p): the sphere's own distance, turned round, small near the wall and largest at the middle. At the wall, the normal is turned round too (-orbNormal), so it faces the ray, and refract(rd, n, ior) bends the ray back out. A ray that meets the wall too slantwise can't leave at all: refract gives back a zero vector, and the ray reflects back in instead. This is total internal reflection, and the loop follows it up to four walls. The room shows through the orb upside down and shrunk, as in any glass ball. Set ior to 1 and the orb disappears.
stage-3-refraction.metal21 new lines since stage 2
// Stage 3 · Bent light
// Now glass. A ray that enters glass bends toward the normal: refract does it, by how much slower light goes in
// glass (ior). Inside, the ray is marched again to the far wall, where it bends out the other way. The room shows
// through, upside down, as in a real glass ball.
// @param ior 1.5 1.0 2.4 How strongly the glass bends light: 1 is air, 1.5 glass, 2.4 diamond
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
// 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));
}
constant float FLOOR = -1.0;
// Where the light comes from: high, behind and to the left.
float3 lightDir() {
return normalize(float3(-0.6, 1.0, -0.4));
}
// The floor: tiles of fired clay with dark grout, and the orb's shadow cast
// along the light.
float floorLevel(float3 p, float3 l) {
float2 g = p.xz * 1.6;
float2 f = abs(fract(g) - 0.5);
float grout = smoothstep(0.47, 0.44, max(f.x, f.y));
float2 id = floor(g);
float tone = 0.28 + 0.08 * fract(sin(dot(id, float2(12.9, 78.2))) * 437.5);
float3 toOrb = -p;
float along = dot(toOrb, l);
float miss = length(toOrb - l * along);
float shadow = along > 0.0 ? smoothstep(0.55, 1.15, miss) : 1.0;
return tone * grout * (0.35 + 0.65 * shadow);
}
// The dark of the kiln, warmer toward the horizon, its hot mouth glowing
// where the light comes from.
float sky(float3 rd, float3 l) {
float up = abs(rd.y);
float mouth = pow(max(dot(rd, l), 0.0), 6.0);
return 0.08 + 0.14 * exp(-up * 6.0) + 0.55 * mouth;
}
// What a ray sees of the room: the floor if it heads down, fading into the
// haze of the far kiln, otherwise the kiln's dark.
float env(float3 ro, float3 rd) {
float3 l = lightDir();
float back = sky(rd, l);
if (rd.y < -0.001) {
float d = (FLOOR - ro.y) / rd.y;
if (d > 0.0) {
float3 p = ro + rd * d;
return mix(back, floorLevel(p, l), exp(-d * 0.12));
}
}
return back;
}
// The camera circles the orb, rising and falling a little.
float3 cameraPos(float t) {
float a = t * 0.25;
return float3(3.6 * sin(a), 0.55 + 0.35 * sin(t * 0.21), 3.6 * cos(a));
}
// The orb: a sphere of radius 1, resting on the floor.
float orb(float3 p) {
return length(p) - 1.0;
}
float3 orbNormal(float3 p) {
const float2 e = float2(0.001, 0.0);
return normalize(float3(
orb(p + e.xyy) - orb(p - e.xyy),
orb(p + e.yxy) - orb(p - e.yxy),
orb(p + e.yyx) - orb(p - e.yyx)));
}
// Inside the glass the ray is marched again. The distance to the wall from
// inside is -orb: the sphere's distance turned round. At the wall the ray bends
// out; one too slantwise to leave reflects back in and goes on.
float inside(float3 p, float3 rd, float ior) {
for (int bounce = 0; bounce < 4; bounce++) {
float d = 0.0;
float3 q = p;
for (int i = 0; i < 64; i++) {
q = p + rd * d;
float wall = -orb(q);
if (wall < 0.001) break;
d += wall;
}
// The normal points out; facing the ray, it points in.
float3 n = -orbNormal(q);
float3 out = refract(rd, n, ior);
if (dot(out, out) > 1e-4) return env(q + out * 0.004, out);
rd = reflect(rd, n);
p = q + rd * 0.004;
}
return env(p, rd);
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float t = u.time * 0.5;
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(float3(0.0, -0.15, 0.0) - 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.2 * fw);
// March to the orb: step by the distance to it until the ray touches it.
float dist = 0.0;
bool hit = false;
for (int i = 0; i < 128; i++) {
float d = orb(ro + rd * dist);
if (d < 0.001) { hit = true; break; }
dist += d;
if (dist > 12.0) break;
}
float shift = (p.heat - 0.5) * 0.4;
if (!hit) return float4(kiln(env(ro, rd) + shift), 1.0);
float3 pos = ro + rd * dist;
float3 n = orbNormal(pos);
// Bend into the glass: from air (1) into glass (ior), so the ratio is
// 1 / ior. Start a hair inside.
float3 rin = refract(rd, n, 1.0 / p.ior);
float through = inside(pos - n * 0.004, rin, p.ior);
return float4(kiln(through + shift), 1.0);
}3 lines from stage 2 that are gone
float3 rr = reflect(rd, n);
float mirror = env(pos + n * 0.004, rr);
return float4(kiln(mirror + shift), 1.0);Stage 4 · Fresnel
Now both at once. fresnel(cosi, ior) is Schlick's approximation: straight on, glass reflects only f0 = ((ior - 1) / (ior + 1))², 4% at an ior of 1.5; toward a grazing angle the reflected share climbs to all of it, by (1 - cos)⁵. The picture is f of the mirror plus 1 - f of what came through, so the orb's middle is clear and its rim turns to mirror.
Fresnel splits the light again at every wall inside: some leaves, and the rest reflects in and is followed, carrying its share (carry *= f). Glass also takes a little of each colour for every unit the light crosses it, blue the most (exp(-travelled * tint * (0.25, 0.9, 1.6)), Beer's law), so light that went further through the glass comes out darker and warmer. The brightness picks the kiln colour and the tint is kept on top of it. A sharp highlight, pow(dot(rr, l), 120), is the light itself in the mirror.
stage-4-fresnel.metal32 new lines since stage 3
// Stage 4 · Fresnel
// Real glass does both at once: some light bounces off, the rest goes in. Fresnel says how much: straight on,
// glass reflects only a twenty-fifth, at a grazing angle nearly all, so the rim of the orb turns to mirror. The
// light that crosses the glass is tinted by the distance it went, and the light itself shines off the top.
// @param ior 1.5 1.0 2.4 How strongly the glass bends light: 1 is air, 1.5 glass, 2.4 diamond
// @param tint 0.4 0.0 1.0 How much colour the glass takes from the light that crosses it
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
// 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));
}
constant float FLOOR = -1.0;
// Where the light comes from: high, behind and to the left.
float3 lightDir() {
return normalize(float3(-0.6, 1.0, -0.4));
}
// The floor: tiles of fired clay with dark grout, and the orb's shadow cast
// along the light.
float floorLevel(float3 p, float3 l) {
float2 g = p.xz * 1.6;
float2 f = abs(fract(g) - 0.5);
float grout = smoothstep(0.47, 0.44, max(f.x, f.y));
float2 id = floor(g);
float tone = 0.28 + 0.08 * fract(sin(dot(id, float2(12.9, 78.2))) * 437.5);
float3 toOrb = -p;
float along = dot(toOrb, l);
float miss = length(toOrb - l * along);
float shadow = along > 0.0 ? smoothstep(0.55, 1.15, miss) : 1.0;
return tone * grout * (0.35 + 0.65 * shadow);
}
// The dark of the kiln, warmer toward the horizon, its hot mouth glowing
// where the light comes from.
float sky(float3 rd, float3 l) {
float up = abs(rd.y);
float mouth = pow(max(dot(rd, l), 0.0), 6.0);
return 0.08 + 0.14 * exp(-up * 6.0) + 0.55 * mouth;
}
// What a ray sees of the room: the floor if it heads down, fading into the
// haze of the far kiln, otherwise the kiln's dark.
float env(float3 ro, float3 rd) {
float3 l = lightDir();
float back = sky(rd, l);
if (rd.y < -0.001) {
float d = (FLOOR - ro.y) / rd.y;
if (d > 0.0) {
float3 p = ro + rd * d;
return mix(back, floorLevel(p, l), exp(-d * 0.12));
}
}
return back;
}
// The camera circles the orb, rising and falling a little.
float3 cameraPos(float t) {
float a = t * 0.25;
return float3(3.6 * sin(a), 0.55 + 0.35 * sin(t * 0.21), 3.6 * cos(a));
}
// The orb: a sphere of radius 1, resting on the floor.
float orb(float3 p) {
return length(p) - 1.0;
}
float3 orbNormal(float3 p) {
const float2 e = float2(0.001, 0.0);
return normalize(float3(
orb(p + e.xyy) - orb(p - e.xyy),
orb(p + e.yxy) - orb(p - e.yxy),
orb(p + e.yyx) - orb(p - e.yyx)));
}
// Schlick's Fresnel: how much light a surface reflects at this angle. Straight
// on, glass reflects only a little (f0); at a grazing angle almost all of it.
float fresnel(float cosi, float ior) {
float f0 = (ior - 1.0) / (ior + 1.0);
f0 *= f0;
return f0 + (1.0 - f0) * pow(1.0 - clamp(cosi, 0.0, 1.0), 5.0);
}
// Inside, marched again as before, but at each wall Fresnel splits the light
// once more: some leaves, the rest reflects in and is followed. Glass takes
// a little of each colour per unit crossed, blue most, so the light that went
// further comes out warmer and dimmer.
float3 inside(float3 p, float3 rd, constant Params& pr) {
float travelled = 0.0;
float3 light = float3(0.0);
float carry = 1.0;
for (int bounce = 0; bounce < 4; bounce++) {
float d = 0.0;
float3 q = p;
for (int i = 0; i < 64; i++) {
q = p + rd * d;
float wall = -orb(q);
if (wall < 0.001) break;
d += wall;
}
travelled += d;
float3 absorb = exp(-travelled * pr.tint * float3(0.25, 0.9, 1.6));
float3 n = -orbNormal(q);
float3 out = refract(rd, n, pr.ior);
float3 back = reflect(rd, n);
if (dot(out, out) > 1e-4) {
float f = fresnel(dot(-rd, n), pr.ior);
light += carry * absorb * (1.0 - f) * env(q + out * 0.004, out);
carry *= f;
}
p = q + back * 0.004;
rd = back;
}
float3 absorb = exp(-travelled * pr.tint * float3(0.25, 0.9, 1.6));
return light + carry * absorb * env(p, rd);
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float t = u.time * 0.5;
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(float3(0.0, -0.15, 0.0) - 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.2 * fw);
// March to the orb: step by the distance to it until the ray touches it.
float dist = 0.0;
bool hit = false;
for (int i = 0; i < 128; i++) {
float d = orb(ro + rd * dist);
if (d < 0.001) { hit = true; break; }
dist += d;
if (dist > 12.0) break;
}
float shift = (p.heat - 0.5) * 0.4;
if (!hit) return float4(kiln(env(ro, rd) + shift), 1.0);
float3 pos = ro + rd * dist;
float3 n = orbNormal(pos);
// Fresnel splits the ray: reflected off the outside, refracted in.
float f = fresnel(dot(-rd, n), p.ior);
float3 rr = reflect(rd, n);
float mirror = env(pos + rr * 0.004, rr);
float3 rin = refract(rd, n, 1.0 / p.ior);
float3 through = inside(pos - n * 0.004, rin, p);
// A sharp highlight where the surface mirrors the light itself.
float spec = pow(max(dot(rr, lightDir()), 0.0), 120.0) * 3.0;
// Each colour went through the glass on its own: the brightness picks
// the kiln colour, and the glass's tint is kept on top of it.
float3 seen = (1.0 - f) * through + f * mirror + spec;
float lum = dot(seen, float3(0.3, 0.5, 0.2));
float3 col = kiln(lum + shift)
* mix(float3(1.0), seen / max(lum, 1e-3), 0.35);
return float4(clamp(col, 0.0, 1.0), 1.0);
}8 lines from stage 3 that are gone
float inside(float3 p, float3 rd, float ior) {
⋯
float3 out = refract(rd, n, ior);
if (dot(out, out) > 1e-4) return env(q + out * 0.004, out);
rd = reflect(rd, n);
p = q + rd * 0.004;
⋯
return env(p, rd);
⋯
float through = inside(pos - n * 0.004, rin, p.ior);
return float4(kiln(through + shift), 1.0);Stage 5 · Glass orb
The last step puts something in the glass and lets it move.
- The ember.
core(p)is episode 3's ring, small and tumbling at the middle of the orb. Inside the glass the march now measures two distances, the wall's (-orb) and the ember's, and steps by the smaller one; a ray that touches the ember takes its glow, hot where the ring faces the ray and flickering slowly. Seen through the glass, the ring is magnified and bent, more so near the rim, and at aniorof 2.4 it fills the orb. - The ripple. The orb's surface moves in slow waves,
0.06 * wobbletimes a product of three sines. A rippled sphere is no longer an exact distance, so both marches step 80% of the way each time to stay safe. - The lens. A glass ball focuses light, so under it, inside its shadow, a bright spot gathers:
exp(-miss² * 18), wheremissis how far a point on the floor is from the line through the orb along the light. The ember's glow also reaches the tiles around the orb. - The camera circles once every 50 seconds at
speed0.5, rising and falling a little.
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 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(float3(0.0, -0.15, 0.0) - 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.2 * fw);
// March to the orb from outside.
float dist = 0.0;
bool hit = false;
for (int i = 0; i < 128; i++) {
float d = orb(ro + rd * dist, t, p.wobble);
if (d < 0.001) { hit = true; break; }
dist += d * 0.8;
if (dist > 12.0) break;
}
float3 col;
if (!hit) {
col = kiln(env(ro, rd, t, p.ember) + (p.heat - 0.5) * 0.4);
} else {
float3 pos = ro + rd * dist;
float3 n = orbNormal(pos, t, p.wobble);
float cosi = dot(-rd, n);
// Fresnel splits the ray: reflected off the outside, refracted in.
float f = fresnel(cosi, p.ior);
float3 rr = reflect(rd, n);
float mirror = env(pos + rr * 0.004, rr, t, p.ember);
float3 rin = refract(rd, n, 1.0 / p.ior);
float3 through = inside(pos - n * 0.004, rin, t, p);
// A sharp highlight where the surface mirrors the light itself.
float3 l = lightDir(t);
float spec = pow(max(dot(rr, l), 0.0), 120.0) * 3.0;
// The glass passes colour on: each channel goes through the palette
// on its own, so a tint made inside keeps its hue.
float shift = (p.heat - 0.5) * 0.4;
float3 seen = (1.0 - f) * through + f * mirror + spec;
float lum = dot(seen, float3(0.3, 0.5, 0.2));
col = kiln(lum + shift) * mix(float3(1.0), seen / max(lum, 1e-3),
0.35);
}
return float4(clamp(col, 0.0, 1.0), 1.0);
}episode-12-glass-orb.metal, the whole file132 new lines since stage 4
// Ray Kiln · Episode 12 · Glass Orb
//
// @episode 12 Glass Orb
// @length 60
// @variation 30
// @variations 1,2,3,4,5,8,9,11
// @still 18.0
// @teaches Refraction, reflection, Fresnel
// @category Fragment
// @tags raymarching, signed distance fields, refraction, reflection, fresnel, procedural sound
// @final Glass orb | A slowly breathing orb of glass with an ember caught inside, on a floor of kiln tiles, the light it gathers spilled beneath it.
//
// An orb of glass on a tiled floor in a dark kiln. A ray that meets glass splits in two: some of the light bounces
// off as a mirror would, the rest bends into the glass, crosses it and bends again on the way out. How much goes
// each way depends on the angle, which is the Fresnel effect. Inside, the bent ray is marched again, so it can find
// the ember that glows at the orb's heart.
// 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 circles and the orb breathes
// @param ior 1.5 1.0 2.4 How strongly the glass bends light: 1 is air, 1.5 glass, 2.4 diamond
// @param tint 0.4 0.0 1.0 How much colour the glass takes from the light that crosses it
// @param wobble 0.3 0.0 1.0 How far the orb's surface ripples away from a perfect sphere
// @param ember 0.7 0.0 1.0 How brightly the ember inside the orb glows
// @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 FLOOR = -1.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 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));
}
// Episode 3's ring.
float sdTorus(float3 p, float R, float r) {
float2 q = float2(length(p.xz) - R, p.y);
return length(q) - r;
}
// ---- The orb ---------------------------------------------------------------------------------------------------
// The glass: a sphere of radius 1 whose surface ripples in slow waves. The
// ripple is small, so the distance stays close enough to march by.
float orb(float3 p, float t, float wobble) {
float w = sin(3.0 * p.x + t) * sin(3.0 * p.y + t * 1.3)
* sin(3.0 * p.z - t * 0.7);
return length(p) - 1.0 + 0.06 * wobble * w;
}
// The ember: a ring tumbling at the orb's heart.
float core(float3 p, float t) {
p.xy = rot(p.xy, t * 0.7);
p.yz = rot(p.yz, t * 0.5);
return sdTorus(p, 0.32, 0.07);
}
float3 orbNormal(float3 p, float t, float wobble) {
const float2 e = float2(0.001, 0.0);
return normalize(float3(
orb(p + e.xyy, t, wobble) - orb(p - e.xyy, t, wobble),
orb(p + e.yxy, t, wobble) - orb(p - e.yxy, t, wobble),
orb(p + e.yyx, t, wobble) - orb(p - e.yyx, t, wobble)));
}
float3 coreNormal(float3 p, float t) {
const float2 e = float2(0.001, 0.0);
return normalize(float3(
core(p + e.xyy, t) - core(p - e.xyy, t),
core(p + e.yxy, t) - core(p - e.yxy, t),
core(p + e.yyx, t) - core(p - e.yyx, t)));
}
// ---- The room ---------------------------------------------------------------------------------------------------
// Where the light comes from: high, behind and to the left.
float3 lightDir(float t) {
return normalize(float3(-0.6, 1.0, -0.4));
}
// The floor: tiles of fired clay with dark grout. Under the orb the light it
// gathers lands as a bright spot, and around that its shadow.
float floorLevel(float3 p, float3 l, float glow) {
float2 g = p.xz * 1.6;
float2 f = abs(fract(g) - 0.5);
float grout = smoothstep(0.47, 0.44, max(f.x, f.y));
float2 id = floor(g);
float tone = 0.28 + 0.08 * fract(sin(dot(id, float2(12.9, 78.2))) * 437.5);
// The orb's shadow, cast along the light onto the floor.
float3 toOrb = -p;
float along = dot(toOrb, l);
float miss = length(toOrb - l * along);
float shadow = along > 0.0 ? smoothstep(0.55, 1.15, miss) : 1.0;
// The caustic: the orb works as a lens, so the light it bends gathers in
// a spot inside the shadow.
float focus = exp(-miss * miss * 18.0) * step(0.0, along);
float level = tone * grout * (0.35 + 0.65 * shadow) + 0.9 * focus;
// The ember's own glow reaches the floor nearby.
level += glow * 0.35 * exp(-length(p.xz) * 1.5);
return level;
}
// The dark of the kiln, warmer toward the horizon, its hot mouth glowing
// where the light comes from.
float sky(float3 rd, float3 l) {
float up = abs(rd.y);
float mouth = pow(max(dot(rd, l), 0.0), 6.0);
return 0.08 + 0.14 * exp(-up * 6.0) + 0.55 * mouth;
}
// Everything a ray sees once it leaves the glass: the floor if it heads down,
// fading into the haze of the far kiln, otherwise the kiln's dark itself.
float env(float3 ro, float3 rd, float t, float glow) {
float3 l = lightDir(t);
float back = sky(rd, l);
if (rd.y < -0.001) {
float d = (FLOOR - ro.y) / rd.y;
float3 p = ro + rd * d;
if (d > 0.0) return mix(back, floorLevel(p, l, glow), exp(-d * 0.12));
}
return back;
}
// ---- Through the glass ------------------------------------------------------------------------------------------
// Schlick's Fresnel: how much light a surface reflects at this angle. Straight
// on, glass reflects only a little (f0); at a grazing angle almost all of it.
float fresnel(float cosi, float ior) {
float f0 = (ior - 1.0) / (ior + 1.0);
f0 *= f0;
return f0 + (1.0 - f0) * pow(1.0 - clamp(cosi, 0.0, 1.0), 5.0);
}
// A ray inside the glass, from where it entered. It is marched again: the
// distance inside is -orb (how far to the wall) and the ember's own distance.
// It ends on the ember, or on the wall, where it bends out; a ray that meets
// the wall too slantwise can't leave and reflects back in (total internal
// reflection); after four walls what is left goes out. The light it carries
// is dimmed and coloured by how far it went through the glass.
float3 inside(float3 p, float3 rd, float t, constant Params& pr) {
float travelled = 0.0;
float3 light = float3(0.0);
float carry = 1.0;
float glow = pr.ember;
for (int bounce = 0; bounce < 4; bounce++) {
float d = 0.0;
bool onCore = false;
float3 q = p;
for (int i = 0; i < 80; i++) {
q = p + rd * d;
float wall = -orb(q, t, pr.wobble);
float c = core(q, t);
if (c < 0.001) { onCore = true; break; }
if (wall < 0.001) break;
d += min(wall, c) * 0.8;
}
travelled += d;
float3 absorb = exp(-travelled * pr.tint * float3(0.25, 0.9, 1.6));
if (onCore) {
// The ember: hot where it faces the way the ray came, darker at
// its edges, flickering slowly.
float3 n = coreNormal(q, t);
float face = max(dot(n, -rd), 0.0);
float flick = 0.85 + 0.15 * sin(t * 7.0 + q.y * 9.0);
float e = (0.35 + 0.65 * face) * flick * glow * 1.6;
light += carry * absorb * e;
return light;
}
float3 n = -orbNormal(q, t, pr.wobble);
float3 out = refract(rd, n, pr.ior);
float3 back = reflect(rd, n);
if (dot(out, out) < 1e-4) {
// Total internal reflection: all of it stays in.
p = q + back * 0.004;
rd = back;
continue;
}
// Leaving: Fresnel splits it again; the part reflected inside is
// followed, the rest goes out into the room.
float f = fresnel(dot(-rd, n), pr.ior);
float3 o = q + out * 0.004;
light += carry * absorb * (1.0 - f) * env(o, out, t, glow);
carry *= f;
p = q + back * 0.004;
rd = back;
}
// Still inside after four walls: let what is left out where it is.
float3 absorb = exp(-travelled * pr.tint * float3(0.25, 0.9, 1.6));
return light + carry * absorb * env(p, rd, t, glow);
}
float3 cameraPos(float t) {
float a = t * 0.25;
return float3(3.6 * sin(a), 0.55 + 0.35 * sin(t * 0.21), 3.6 * cos(a));
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float t = u.time * p.speed;
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(float3(0.0, -0.15, 0.0) - 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.2 * fw);
// March to the orb from outside.
float dist = 0.0;
bool hit = false;
for (int i = 0; i < 128; i++) {
float d = orb(ro + rd * dist, t, p.wobble);
if (d < 0.001) { hit = true; break; }
dist += d * 0.8;
if (dist > 12.0) break;
}
float3 col;
if (!hit) {
col = kiln(env(ro, rd, t, p.ember) + (p.heat - 0.5) * 0.4);
} else {
float3 pos = ro + rd * dist;
float3 n = orbNormal(pos, t, p.wobble);
float cosi = dot(-rd, n);
// Fresnel splits the ray: reflected off the outside, refracted in.
float f = fresnel(cosi, p.ior);
float3 rr = reflect(rd, n);
float mirror = env(pos + rr * 0.004, rr, t, p.ember);
float3 rin = refract(rd, n, 1.0 / p.ior);
float3 through = inside(pos - n * 0.004, rin, t, p);
// A sharp highlight where the surface mirrors the light itself.
float3 l = lightDir(t);
float spec = pow(max(dot(rr, l), 0.0), 120.0) * 3.0;
// The glass passes colour on: each channel goes through the palette
// on its own, so a tint made inside keeps its hue.
float shift = (p.heat - 0.5) * 0.4;
float3 seen = (1.0 - f) * through + f * mirror + spec;
float lum = dot(seen, float3(0.3, 0.5, 0.2));
col = kiln(lum + shift) * mix(float3(1.0), seen / max(lum, 1e-3),
0.35);
}
return float4(clamp(col, 0.0, 1.0), 1.0);
}
// ---- Sound -----------------------------------------------------------------------------------------------------
// Glass that rings. Every so often the orb is struck, softly, and rings with a glass bell's partials: not a string's
// whole-number overtones but the stretched ones of a struck bowl. The note comes from a pentatonic scale, and the
// glass's bending sets the key: more bending, higher. Under the bells, the ember hums, and its hum grows with its
// glow; the orb's ripple makes the hum beat.
// Episode 5's integer hash.
float hashU(uint n) {
n = (n << 13u) ^ n;
n = n * (n * n * 15731u + 789221u) + 1376312589u;
return float(n & 0x7fffffffu) / float(0x7fffffff);
}
// A struck glass bowl: four partials at stretched ratios, the high ones dying first.
float glassBell(float f, float s) {
float a = sin(TAU * f * s) * exp(-s * 1.2);
a += 0.5 * sin(TAU * f * 2.76 * s) * exp(-s * 2.4);
a += 0.25 * sin(TAU * f * 5.4 * s) * exp(-s * 4.0);
a += 0.12 * sin(TAU * f * 8.93 * s) * exp(-s * 6.0);
return a * (1.0 - exp(-s * 300.0));
}
float2 sound(float t, constant Params& p) {
// The bells: one strike every 1.6 seconds, on a note of a pentatonic scale
// picked by hash; the key rises with the bending (ior).
float key = 392.0 * exp2((p.ior - 1.5) * 0.5);
float2 bells = float2(0.0);
float slot = floor(t / 1.6);
for (int k = -3; k <= 0; k++) {
float j = slot + float(k);
if (j < 0.0) continue;
float s = t - j * 1.6;
uint id = uint(j);
float pick = hashU(id * 3u + 1u);
float rank = floor(pick * 5.0);
float semis = rank < 1.0 ? 0.0 : rank < 2.0 ? 2.0 : rank < 3.0 ? 4.0
: rank < 4.0 ? 7.0 : 9.0;
float up = hashU(id * 3u + 2u) < 0.3 ? 12.0 : 0.0;
float f = key * exp2((semis + up) / 12.0);
float soft = 0.5 + 0.5 * hashU(id * 3u + 3u);
float pan = 0.5 + 0.35 * sin(j * 2.3);
float b = glassBell(f, s) * soft;
bells += float2(1.0 - pan, pan) * b;
}
bells *= 0.55;
// The ember's hum: a low E and its fifth, each ear a little apart; the
// ripple of the glass sets how fast they beat.
float beat = 0.3 + 2.0 * p.wobble;
float hum = sin(TAU * 82.41 * t) + 0.5 * sin(TAU * 123.47 * t);
float humR = sin(TAU * (82.41 + beat) * t + 0.7)
+ 0.5 * sin(TAU * (123.47 + beat * 0.5) * t);
float flick = 0.8 + 0.2 * sin(t * p.speed * 7.0);
float2 drone = float2(hum, humR) * 0.16 * p.ember * flick;
float fadeIn = smoothstep(0.0, 2.0, t);
return tanh((bells + drone) * 1.4) * 0.7 * fadeIn * p.volume;
}44 lines from stage 4 that are gone
constant float FLOOR = -1.0;
⋯
float3 lightDir() {
⋯
float floorLevel(float3 p, float3 l) {
⋯
return tone * grout * (0.35 + 0.65 * shadow);
⋯
float env(float3 ro, float3 rd) {
float3 l = lightDir();
⋯
if (d > 0.0) {
⋯
return mix(back, floorLevel(p, l), exp(-d * 0.12));
}
⋯
float3 cameraPos(float t) {
float a = t * 0.25;
return float3(3.6 * sin(a), 0.55 + 0.35 * sin(t * 0.21), 3.6 * cos(a));
}
⋯
float orb(float3 p) {
return length(p) - 1.0;
}
⋯
float3 orbNormal(float3 p) {
const float2 e = float2(0.001, 0.0);
return normalize(float3(
orb(p + e.xyy) - orb(p - e.xyy),
orb(p + e.yxy) - orb(p - e.yxy),
orb(p + e.yyx) - orb(p - e.yyx)));
}
⋯
float3 inside(float3 p, float3 rd, constant Params& pr) {
⋯
for (int i = 0; i < 64; i++) {
⋯
float wall = -orb(q);
⋯
d += wall;
⋯
float3 n = -orbNormal(q);
⋯
if (dot(out, out) > 1e-4) {
⋯
light += carry * absorb * (1.0 - f) * env(q + out * 0.004, out);
⋯
}
⋯
return light + carry * absorb * env(p, rd);
⋯
float t = u.time * 0.5;
⋯
float d = orb(ro + rd * dist);
⋯
dist += d;
⋯
float shift = (p.heat - 0.5) * 0.4;
if (!hit) return float4(kiln(env(ro, rd) + shift), 1.0);
⋯
float3 n = orbNormal(pos);
⋯
float f = fresnel(dot(-rd, n), p.ior);
⋯
float mirror = env(pos + rr * 0.004, rr);
⋯
float3 through = inside(pos - n * 0.004, rin, p);
⋯
float spec = pow(max(dot(rr, lightDir()), 0.0), 120.0) * 3.0;
⋯
float3 col = kiln(lum + shift)
* mix(float3(1.0), seen / max(lum, 1e-3), 0.35);The sound
The sound is worked out from the time alone and reads the same sliders as the picture.
- The glass bells. Every 1.6 seconds the orb is struck, softly. A struck glass bowl doesn't ring with a string's whole-number overtones but with stretched ones:
glassBelladds partials at 1, 2.76, 5.4 and 8.93 times the note, each dying faster than the one below. The note is picked by a hash of the strike's number from a major pentatonic scale, sometimes an octave up, and each strike is placed somewhere between the ears. The key rises withior: the more the glass bends, the higher it rings. - The ember's hum. A low E and the B above it, a little apart in each ear so they beat; the more the orb ripples (
wobble), the faster the beating. It is as loud as the ember is bright, and it flickers with it.
Everything goes through tanh and fades in over the first two seconds. At the defaults the peak is about -7 dBFS and the average (RMS) about -21 dBFS.
New in Ray Kiln
The cost view. In the playground, Cost under the picture shows what each pixel costs instead of what it looks like: how many loop turns shade took there, counting every loop in shade and in the functions it calls, as heat on a log scale, black for none and white at the number in white at (256 to start). Point at the picture and it gives the count there. It counts loop turns, not every instruction, so it shows where a shader marches and repeats, which is where its time usually goes. It works for any fragment shader, takes the place of Show a value while it is on, and is free.
This episode is made to be looked at this way. A ray that misses the orb costs about ten turns, a few more as it passes close to the glass, which shows as rings of warmth around it. A ray that hits the glass is marched a second time inside, and again after each reflection there: where the light bounces round the inside of the rim it costs a hundred turns or more, and the orb glows white. Through the middle, the march stops early on the ember, so the ring shows darker than the glass around it, at about fifty.
Try this
- Set
iorto 1: the glass bends nothing, and only its reflections and the ember are left. At 2.4, diamond, the ring fills the orb and the rim is all mirror. - Set
tintto 1 andemberto 0: thick glass swallows the blue and the green, and the dark ring shows as a shadow inside it. - In
inside, changebounce < 4tobounce < 1: light that reflects inside the glass is let out at once, and the rim loses its glints. - In
fresnel, returnf0alone: the rim stops turning to mirror, and the orb looks painted on. - In
core, changesdTorus(p, 0.32, 0.07)tolength(p) - 0.25for a glowing ball inside the glass.
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 circles and the orb breathes |
ior | 1.5 | 1.0 to 2.4 | How strongly the glass bends light: 1 is air, 1.5 glass, 2.4 diamond |
tint | 0.4 | 0.0 to 1.0 | How much colour the glass takes from the light that crosses it |
wobble | 0.3 | 0.0 to 1.0 | How far the orb's surface ripples away from a perfect sphere |
ember | 0.7 | 0.0 to 1.0 | How brightly the ember inside the orb glows |
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-12-glass-orb.metal
// Ray Kiln · Episode 12 · Glass Orb
//
// @episode 12 Glass Orb
// @length 60
// @variation 30
// @variations 1,2,3,4,5,8,9,11
// @still 18.0
// @teaches Refraction, reflection, Fresnel
// @category Fragment
// @tags raymarching, signed distance fields, refraction, reflection, fresnel, procedural sound
// @final Glass orb | A slowly breathing orb of glass with an ember caught inside, on a floor of kiln tiles, the light it gathers spilled beneath it.
//
// An orb of glass on a tiled floor in a dark kiln. A ray that meets glass splits in two: some of the light bounces
// off as a mirror would, the rest bends into the glass, crosses it and bends again on the way out. How much goes
// each way depends on the angle, which is the Fresnel effect. Inside, the bent ray is marched again, so it can find
// the ember that glows at the orb's heart.
// 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 circles and the orb breathes
// @param ior 1.5 1.0 2.4 How strongly the glass bends light: 1 is air, 1.5 glass, 2.4 diamond
// @param tint 0.4 0.0 1.0 How much colour the glass takes from the light that crosses it
// @param wobble 0.3 0.0 1.0 How far the orb's surface ripples away from a perfect sphere
// @param ember 0.7 0.0 1.0 How brightly the ember inside the orb glows
// @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 FLOOR = -1.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 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));
}
// Episode 3's ring.
float sdTorus(float3 p, float R, float r) {
float2 q = float2(length(p.xz) - R, p.y);
return length(q) - r;
}
// ---- The orb ---------------------------------------------------------------------------------------------------
// The glass: a sphere of radius 1 whose surface ripples in slow waves. The
// ripple is small, so the distance stays close enough to march by.
float orb(float3 p, float t, float wobble) {
float w = sin(3.0 * p.x + t) * sin(3.0 * p.y + t * 1.3)
* sin(3.0 * p.z - t * 0.7);
return length(p) - 1.0 + 0.06 * wobble * w;
}
// The ember: a ring tumbling at the orb's heart.
float core(float3 p, float t) {
p.xy = rot(p.xy, t * 0.7);
p.yz = rot(p.yz, t * 0.5);
return sdTorus(p, 0.32, 0.07);
}
float3 orbNormal(float3 p, float t, float wobble) {
const float2 e = float2(0.001, 0.0);
return normalize(float3(
orb(p + e.xyy, t, wobble) - orb(p - e.xyy, t, wobble),
orb(p + e.yxy, t, wobble) - orb(p - e.yxy, t, wobble),
orb(p + e.yyx, t, wobble) - orb(p - e.yyx, t, wobble)));
}
float3 coreNormal(float3 p, float t) {
const float2 e = float2(0.001, 0.0);
return normalize(float3(
core(p + e.xyy, t) - core(p - e.xyy, t),
core(p + e.yxy, t) - core(p - e.yxy, t),
core(p + e.yyx, t) - core(p - e.yyx, t)));
}
// ---- The room ---------------------------------------------------------------------------------------------------
// Where the light comes from: high, behind and to the left.
float3 lightDir(float t) {
return normalize(float3(-0.6, 1.0, -0.4));
}
// The floor: tiles of fired clay with dark grout. Under the orb the light it
// gathers lands as a bright spot, and around that its shadow.
float floorLevel(float3 p, float3 l, float glow) {
float2 g = p.xz * 1.6;
float2 f = abs(fract(g) - 0.5);
float grout = smoothstep(0.47, 0.44, max(f.x, f.y));
float2 id = floor(g);
float tone = 0.28 + 0.08 * fract(sin(dot(id, float2(12.9, 78.2))) * 437.5);
// The orb's shadow, cast along the light onto the floor.
float3 toOrb = -p;
float along = dot(toOrb, l);
float miss = length(toOrb - l * along);
float shadow = along > 0.0 ? smoothstep(0.55, 1.15, miss) : 1.0;
// The caustic: the orb works as a lens, so the light it bends gathers in
// a spot inside the shadow.
float focus = exp(-miss * miss * 18.0) * step(0.0, along);
float level = tone * grout * (0.35 + 0.65 * shadow) + 0.9 * focus;
// The ember's own glow reaches the floor nearby.
level += glow * 0.35 * exp(-length(p.xz) * 1.5);
return level;
}
// The dark of the kiln, warmer toward the horizon, its hot mouth glowing
// where the light comes from.
float sky(float3 rd, float3 l) {
float up = abs(rd.y);
float mouth = pow(max(dot(rd, l), 0.0), 6.0);
return 0.08 + 0.14 * exp(-up * 6.0) + 0.55 * mouth;
}
// Everything a ray sees once it leaves the glass: the floor if it heads down,
// fading into the haze of the far kiln, otherwise the kiln's dark itself.
float env(float3 ro, float3 rd, float t, float glow) {
float3 l = lightDir(t);
float back = sky(rd, l);
if (rd.y < -0.001) {
float d = (FLOOR - ro.y) / rd.y;
float3 p = ro + rd * d;
if (d > 0.0) return mix(back, floorLevel(p, l, glow), exp(-d * 0.12));
}
return back;
}
// ---- Through the glass ------------------------------------------------------------------------------------------
// Schlick's Fresnel: how much light a surface reflects at this angle. Straight
// on, glass reflects only a little (f0); at a grazing angle almost all of it.
float fresnel(float cosi, float ior) {
float f0 = (ior - 1.0) / (ior + 1.0);
f0 *= f0;
return f0 + (1.0 - f0) * pow(1.0 - clamp(cosi, 0.0, 1.0), 5.0);
}
// A ray inside the glass, from where it entered. It is marched again: the
// distance inside is -orb (how far to the wall) and the ember's own distance.
// It ends on the ember, or on the wall, where it bends out; a ray that meets
// the wall too slantwise can't leave and reflects back in (total internal
// reflection); after four walls what is left goes out. The light it carries
// is dimmed and coloured by how far it went through the glass.
float3 inside(float3 p, float3 rd, float t, constant Params& pr) {
float travelled = 0.0;
float3 light = float3(0.0);
float carry = 1.0;
float glow = pr.ember;
for (int bounce = 0; bounce < 4; bounce++) {
float d = 0.0;
bool onCore = false;
float3 q = p;
for (int i = 0; i < 80; i++) {
q = p + rd * d;
float wall = -orb(q, t, pr.wobble);
float c = core(q, t);
if (c < 0.001) { onCore = true; break; }
if (wall < 0.001) break;
d += min(wall, c) * 0.8;
}
travelled += d;
float3 absorb = exp(-travelled * pr.tint * float3(0.25, 0.9, 1.6));
if (onCore) {
// The ember: hot where it faces the way the ray came, darker at
// its edges, flickering slowly.
float3 n = coreNormal(q, t);
float face = max(dot(n, -rd), 0.0);
float flick = 0.85 + 0.15 * sin(t * 7.0 + q.y * 9.0);
float e = (0.35 + 0.65 * face) * flick * glow * 1.6;
light += carry * absorb * e;
return light;
}
float3 n = -orbNormal(q, t, pr.wobble);
float3 out = refract(rd, n, pr.ior);
float3 back = reflect(rd, n);
if (dot(out, out) < 1e-4) {
// Total internal reflection: all of it stays in.
p = q + back * 0.004;
rd = back;
continue;
}
// Leaving: Fresnel splits it again; the part reflected inside is
// followed, the rest goes out into the room.
float f = fresnel(dot(-rd, n), pr.ior);
float3 o = q + out * 0.004;
light += carry * absorb * (1.0 - f) * env(o, out, t, glow);
carry *= f;
p = q + back * 0.004;
rd = back;
}
// Still inside after four walls: let what is left out where it is.
float3 absorb = exp(-travelled * pr.tint * float3(0.25, 0.9, 1.6));
return light + carry * absorb * env(p, rd, t, glow);
}
float3 cameraPos(float t) {
float a = t * 0.25;
return float3(3.6 * sin(a), 0.55 + 0.35 * sin(t * 0.21), 3.6 * cos(a));
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float t = u.time * p.speed;
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(float3(0.0, -0.15, 0.0) - 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.2 * fw);
// March to the orb from outside.
float dist = 0.0;
bool hit = false;
for (int i = 0; i < 128; i++) {
float d = orb(ro + rd * dist, t, p.wobble);
if (d < 0.001) { hit = true; break; }
dist += d * 0.8;
if (dist > 12.0) break;
}
float3 col;
if (!hit) {
col = kiln(env(ro, rd, t, p.ember) + (p.heat - 0.5) * 0.4);
} else {
float3 pos = ro + rd * dist;
float3 n = orbNormal(pos, t, p.wobble);
float cosi = dot(-rd, n);
// Fresnel splits the ray: reflected off the outside, refracted in.
float f = fresnel(cosi, p.ior);
float3 rr = reflect(rd, n);
float mirror = env(pos + rr * 0.004, rr, t, p.ember);
float3 rin = refract(rd, n, 1.0 / p.ior);
float3 through = inside(pos - n * 0.004, rin, t, p);
// A sharp highlight where the surface mirrors the light itself.
float3 l = lightDir(t);
float spec = pow(max(dot(rr, l), 0.0), 120.0) * 3.0;
// The glass passes colour on: each channel goes through the palette
// on its own, so a tint made inside keeps its hue.
float shift = (p.heat - 0.5) * 0.4;
float3 seen = (1.0 - f) * through + f * mirror + spec;
float lum = dot(seen, float3(0.3, 0.5, 0.2));
col = kiln(lum + shift) * mix(float3(1.0), seen / max(lum, 1e-3),
0.35);
}
return float4(clamp(col, 0.0, 1.0), 1.0);
}
// ---- Sound -----------------------------------------------------------------------------------------------------
// Glass that rings. Every so often the orb is struck, softly, and rings with a glass bell's partials: not a string's
// whole-number overtones but the stretched ones of a struck bowl. The note comes from a pentatonic scale, and the
// glass's bending sets the key: more bending, higher. Under the bells, the ember hums, and its hum grows with its
// glow; the orb's ripple makes the hum beat.
// Episode 5's integer hash.
float hashU(uint n) {
n = (n << 13u) ^ n;
n = n * (n * n * 15731u + 789221u) + 1376312589u;
return float(n & 0x7fffffffu) / float(0x7fffffff);
}
// A struck glass bowl: four partials at stretched ratios, the high ones dying first.
float glassBell(float f, float s) {
float a = sin(TAU * f * s) * exp(-s * 1.2);
a += 0.5 * sin(TAU * f * 2.76 * s) * exp(-s * 2.4);
a += 0.25 * sin(TAU * f * 5.4 * s) * exp(-s * 4.0);
a += 0.12 * sin(TAU * f * 8.93 * s) * exp(-s * 6.0);
return a * (1.0 - exp(-s * 300.0));
}
float2 sound(float t, constant Params& p) {
// The bells: one strike every 1.6 seconds, on a note of a pentatonic scale
// picked by hash; the key rises with the bending (ior).
float key = 392.0 * exp2((p.ior - 1.5) * 0.5);
float2 bells = float2(0.0);
float slot = floor(t / 1.6);
for (int k = -3; k <= 0; k++) {
float j = slot + float(k);
if (j < 0.0) continue;
float s = t - j * 1.6;
uint id = uint(j);
float pick = hashU(id * 3u + 1u);
float rank = floor(pick * 5.0);
float semis = rank < 1.0 ? 0.0 : rank < 2.0 ? 2.0 : rank < 3.0 ? 4.0
: rank < 4.0 ? 7.0 : 9.0;
float up = hashU(id * 3u + 2u) < 0.3 ? 12.0 : 0.0;
float f = key * exp2((semis + up) / 12.0);
float soft = 0.5 + 0.5 * hashU(id * 3u + 3u);
float pan = 0.5 + 0.35 * sin(j * 2.3);
float b = glassBell(f, s) * soft;
bells += float2(1.0 - pan, pan) * b;
}
bells *= 0.55;
// The ember's hum: a low E and its fifth, each ear a little apart; the
// ripple of the glass sets how fast they beat.
float beat = 0.3 + 2.0 * p.wobble;
float hum = sin(TAU * 82.41 * t) + 0.5 * sin(TAU * 123.47 * t);
float humR = sin(TAU * (82.41 + beat) * t + 0.7)
+ 0.5 * sin(TAU * (123.47 + beat * 0.5) * t);
float flick = 0.8 + 0.2 * sin(t * p.speed * 7.0);
float2 drone = float2(hum, humR) * 0.16 * p.ember * flick;
float fadeIn = smoothstep(0.0, 2.0, t);
return tanh((bells + drone) * 1.4) * 0.7 * 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, 0.4, 0.3, 0.7, 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.


