Kiln Fire
A flame with no surface, drawn by walking each ray through rising 3D noise in small steps, adding the light the fire gives off and losing what it swallows, over a floor that breathes with its flicker, with a sound of roaring and crackling noise.
Teaches Volume raymarching through noise, emission
Builds on 01 · Flowing Color Field, 03 · Floating 3D Scene (rotation), 05 · Cracked Clay (integer hash, sound noise)
Watch
The idea
Episode 3 drew solid shapes: march a ray until it touches a surface, then light that one point. Fire has no surface. You see light from all through it at once, from the front, the middle and the back, and the thick parts hide some of what is behind them.
So this episode marches differently. The ray walks through the fire in small, even steps, and at every step it does two things: it adds the light the fire gives off there (emission), and it lets a little less of everything further along through (absorption). That is volume rendering, the way fog, smoke and clouds are drawn too.
What it walks through is noise: episode 1's fractal noise with a third axis, rising, shaped into a column that tapers to nothing. It is built in five steps, and each step is a shader you can run on its own.
Stage 1 · Noise in three dimensions
Episode 1's value noise picks a random value at each corner of a grid of squares and blends between them. vnoise3 does the same on a grid of cubes, so it blends between eight corners instead of four: across x, then y, then z. fbm3 adds five octaves of it, each about twice as fine and half as strong, as fbm did.
The random value at each corner comes from hash31, which turns the corner's whole-number coordinates into a number from 0 to 1 with integer multiplies, adds and shifts, so neighbouring cubes look unrelated.
A picture is flat, so this stage shows one slice through the noise and moves the slice slowly along z. It also takes t * 3 off the height it looks up: each point on screen reads the noise from further down as time goes on, so the pattern moves up the screen. That is how the flame will rise.
stage-1-noise.metal
// Stage 1 · Noise in three dimensions
// Value noise with a third axis. Each frame shows one flat slice through it, and the noise rises through the slice.
// @param speed 0.5 0.0 2.0 How fast the fire rises and the camera sways
// @param scale 3.0 1.0 8.0 How many cubes of noise fit across the picture
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
// A random number from 0 to 1 for each whole-numbered point in 3D, mixed with integer arithmetic so neighbouring
// cubes look unrelated.
float hash31(float3 p) {
uint3 v = uint3(int3(floor(p))) * 1664525u + 1013904223u;
v.x += v.y * v.z; v.y += v.z * v.x; v.z += v.x * v.y;
v ^= v >> 16u;
v.x += v.y * v.z; v.y += v.z * v.x; v.z += v.x * v.y;
return float(v.x) * (1.0 / 4294967295.0);
}
// Value noise in 3D: a random value at each corner of a grid of cubes, blended smoothly across each cube.
float vnoise3(float3 p) {
float3 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash31(i), b = hash31(i + float3(1, 0, 0));
float c = hash31(i + float3(0, 1, 0)), d = hash31(i + float3(1, 1, 0));
float e = hash31(i + float3(0, 0, 1)), g = hash31(i + float3(1, 0, 1));
float h = hash31(i + float3(0, 1, 1)), k = hash31(i + float3(1, 1, 1));
float lo = mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
float hi = mix(mix(e, g, f.x), mix(h, k, f.x), f.y);
return mix(lo, hi, f.z);
}
float fbm3(float3 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; i++) {
v += a * vnoise3(p);
p = p * 2.02 + float3(17.1, 9.2, 4.7);
a *= 0.5;
}
return v;
}
float3 kiln(float t) {
float3 c0 = float3(0.020, 0.012, 0.020);
float3 c1 = float3(0.280, 0.040, 0.030);
float3 c2 = float3(0.880, 0.260, 0.050);
float3 c3 = float3(1.000, 0.680, 0.200);
float3 c4 = float3(1.000, 0.970, 0.840);
t = clamp(t, 0.0, 1.0);
float3 c = mix(c0, c1, smoothstep(0.00, 0.22, t));
c = mix(c, c2, smoothstep(0.22, 0.50, t));
c = mix(c, c3, smoothstep(0.50, 0.76, t));
return mix(c, c4, smoothstep(0.76, 1.00, t));
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float t = u.time * p.speed;
float side = min(u.resolution.x, u.resolution.y);
float2 xy = (uv - 0.5) * u.resolution / side * p.scale;
// The slice moves slowly along z, the third axis, so the pattern changes as it goes. And each point reads the
// noise from further down as time goes on, so the pattern rises up the screen.
float3 pos = float3(xy.x, xy.y - t * 3.0, t * 0.3);
float n = fbm3(pos);
return float4(kiln(n * 1.6 - 0.3 + (p.heat - 0.5) * 0.5), 1.0);
}Stage 2 · Light that adds up
Now a camera, episode 3's, swaying slowly from side to side. The fire's shape comes first, without noise: flameInside says how far a point is inside a cone that is widest near the floor and narrows to nothing at the top, positive inside and negative outside.
The ray can't stop at a surface, so it walks. It only matters where the fire can be, so the code finds where the ray enters and leaves a cylinder of radius 1.4 around the flame, and where it meets the floor, and splits that stretch into 72 even steps. Every step that lands inside the cone adds the same small amount of light. A ray through the middle crosses more of the cone than one through its edge, so the middle comes out brighter, and the cone looks round without any lighting at all.
Starting every ray at the same point in its first step would show the steps as bands across the cone. Each pixel starts at its own random place inside the first step instead, from hash31 of its position, which turns the bands into a fine grain.
stage-2-light.metal32 new lines since stage 1
// Stage 2 · Light that adds up
// A ray can't stop at a fire's surface: it has none. So walk the ray in small steps and add up the light at each step.
// @param speed 0.5 0.0 2.0 How fast the fire rises and the camera sways
// @param height 1.0 0.5 1.6 How tall the flame stands
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
// A random number from 0 to 1 for each whole-numbered point in 3D, mixed with integer arithmetic so neighbouring
// cubes look unrelated.
float hash31(float3 p) {
uint3 v = uint3(int3(floor(p))) * 1664525u + 1013904223u;
v.x += v.y * v.z; v.y += v.z * v.x; v.z += v.x * v.y;
v ^= v >> 16u;
v.x += v.y * v.z; v.y += v.z * v.x; v.z += v.x * v.y;
return float(v.x) * (1.0 / 4294967295.0);
}
// How far inside the flame a point is: positive inside, negative outside. For now a smooth cone: a column that is
// widest near the floor and narrows to nothing at the top.
float flameInside(float3 p, float height) {
float h = clamp(p.y / (2.6 * height), 0.0, 1.0);
float root = smoothstep(-0.05, 0.15, p.y / height);
float radius = 0.7 * pow(1.0 - h, 0.9) * root;
return radius - length(p.xz);
}
// 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;
// Episode 3's camera, swaying slowly, standing further back for a taller flame.
float side = min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution / side * 2.0;
float a = 0.35 * sin(t * 0.21);
float back = 2.6 + 0.8 * p.height;
float3 target = float3(0.0, 0.25 + 0.85 * p.height, 0.0);
float3 ro = float3(back * sin(a), 0.6 + 0.6 * p.height, back * cos(a));
float3 fw = normalize(target - ro);
float3 rt = normalize(cross(fw, float3(0.0, 1.0, 0.0)));
float3 up = cross(rt, fw);
float3 rd = normalize(q.x * rt + q.y * up + 2.0 * fw);
// March only where the flame can be: between where the ray enters and leaves a cylinder of radius 1.4 around it.
float floorDist = rd.y < 0.0 ? -ro.y / rd.y : 1e9;
float aa = dot(rd.xz, rd.xz);
float b = dot(ro.xz, rd.xz);
float c = dot(ro.xz, ro.xz) - 1.4 * 1.4;
float disc = b * b - aa * c;
float light = 0.0;
if (disc > 0.0) {
float enter = max((-b - sqrt(disc)) / aa, 0.0);
float leave = min((-b + sqrt(disc)) / aa, floorDist);
const int STEPS = 72;
float dt = (leave - enter) / float(STEPS);
// Each pixel starts at a random place in the first step, so the steps show as grain, not bands.
float dist = enter + dt * hash31(float3(uv * u.resolution, 3.7));
for (int i = 0; i < STEPS; i++) {
// Each step inside adds the same light, so a ray through the middle adds the most.
if (flameInside(ro + rd * dist, p.height) > 0.0) light += dt * 0.6;
dist += dt;
}
}
return float4(kiln(0.05 + light + (p.heat - 0.5) * 0.5), 1.0);
}23 lines from stage 1 that are gone
float vnoise3(float3 p) {
float3 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash31(i), b = hash31(i + float3(1, 0, 0));
float c = hash31(i + float3(0, 1, 0)), d = hash31(i + float3(1, 1, 0));
float e = hash31(i + float3(0, 0, 1)), g = hash31(i + float3(1, 0, 1));
float h = hash31(i + float3(0, 1, 1)), k = hash31(i + float3(1, 1, 1));
float lo = mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
float hi = mix(mix(e, g, f.x), mix(h, k, f.x), f.y);
return mix(lo, hi, f.z);
}
⋯
float fbm3(float3 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; i++) {
v += a * vnoise3(p);
p = p * 2.02 + float3(17.1, 9.2, 4.7);
a *= 0.5;
}
return v;
⋯
float2 xy = (uv - 0.5) * u.resolution / side * p.scale;
⋯
float3 pos = float3(xy.x, xy.y - t * 3.0, t * 0.3);
float n = fbm3(pos);
return float4(kiln(n * 1.6 - 0.3 + (p.heat - 0.5) * 0.5), 1.0);Stage 3 · Noise in the flame
Two kinds of noise turn the cone into a flame.
- The edge.
riseNoisereadsfbm3at the point, squeezed so its shapes are taller than they are wide, rising att * 3, and twisted a little more the higher up it is. Adding it toflameInsidepushes the edge in and out, by more near the top than at the root, so the top tears into tongues that rise and break away. - The sway. Two slow, single-octave noises push the whole column sideways, more the higher up, so the flame leans and wavers instead of standing straight.
turbulence scales both: at 0 the smooth cone comes back. The fire also thickens over the first 0.12 inside its edge, with smoothstep, so its edge is soft instead of cut out.
stage-3-flame.metal45 new lines since stage 2
// Stage 3 · Noise in the flame
// Push the cone's edge in and out with the rising noise, and the smooth cone becomes a flame.
// @param speed 0.5 0.0 2.0 How fast the fire rises and the camera sways
// @param height 1.0 0.5 1.6 How tall the flame stands
// @param turbulence 0.6 0.0 1.0 How much the noise tears at the flame's edge
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
// A random number from 0 to 1 for each whole-numbered point in 3D, mixed with integer arithmetic so neighbouring
// cubes look unrelated.
float hash31(float3 p) {
uint3 v = uint3(int3(floor(p))) * 1664525u + 1013904223u;
v.x += v.y * v.z; v.y += v.z * v.x; v.z += v.x * v.y;
v ^= v >> 16u;
v.x += v.y * v.z; v.y += v.z * v.x; v.z += v.x * v.y;
return float(v.x) * (1.0 / 4294967295.0);
}
// Value noise in 3D: a random value at each corner of a grid of cubes, blended smoothly across each cube.
float vnoise3(float3 p) {
float3 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash31(i), b = hash31(i + float3(1, 0, 0));
float c = hash31(i + float3(0, 1, 0)), d = hash31(i + float3(1, 1, 0));
float e = hash31(i + float3(0, 0, 1)), g = hash31(i + float3(1, 0, 1));
float h = hash31(i + float3(0, 1, 1)), k = hash31(i + float3(1, 1, 1));
float lo = mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
float hi = mix(mix(e, g, f.x), mix(h, k, f.x), f.y);
return mix(lo, hi, f.z);
}
float fbm3(float3 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; i++) {
v += a * vnoise3(p);
p = p * 2.02 + float3(17.1, 9.2, 4.7);
a *= 0.5;
}
return v;
}
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 noise rises: as time goes on, each point looks up lower in the noise, so the shapes move up.
float riseNoise(float3 p, float t) {
float3 q = p * float3(3.0, 1.8, 3.0) - float3(0.0, t * 3.0, 0.0);
q.xz = rot(q.xz, p.y * 0.8);
return fbm3(q);
}
// The flame's edge, torn by noise. Two slow noises sway it from side to side, more the higher up, and the rising
// noise pushes its edge in and out, so the top tears into tongues.
float flameInside(float3 p, float t, float height, float turbulence) {
float h = clamp(p.y / (2.6 * height), 0.0, 1.0);
float3 s = float3(p.x, p.y - t * 1.5, p.z) * 0.9;
float2 sway = float2(vnoise3(s), vnoise3(s + float3(5.2, 1.3, 8.1)));
sway -= 0.5;
float2 xz = p.xz + sway * h * 1.4 * turbulence;
float root = smoothstep(-0.05, 0.15, p.y / height);
float radius = 0.7 * pow(1.0 - h, 0.9) * root;
float n = riseNoise(p, t) - 0.5;
return radius - length(xz) + n * turbulence * (0.45 + 1.6 * h);
}
// 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;
float a = 0.35 * sin(t * 0.21);
float back = 2.6 + 0.8 * p.height;
float3 target = float3(0.0, 0.25 + 0.85 * p.height, 0.0);
float3 ro = float3(back * sin(a), 0.6 + 0.6 * p.height, back * cos(a));
float3 fw = normalize(target - ro);
float3 rt = normalize(cross(fw, float3(0.0, 1.0, 0.0)));
float3 up = cross(rt, fw);
float3 rd = normalize(q.x * rt + q.y * up + 2.0 * fw);
float level = 0.05;
float floorDist = rd.y < 0.0 ? -ro.y / rd.y : 1e9;
// Where the ray enters and leaves a cylinder of radius 1.4 around the flame: the only stretch worth marching.
float aa = dot(rd.xz, rd.xz);
float b = dot(ro.xz, rd.xz);
float c = dot(ro.xz, ro.xz) - 1.4 * 1.4;
float disc = b * b - aa * c;
if (disc > 0.0) {
float enter = max((-b - sqrt(disc)) / aa, 0.0);
float leave = min((-b + sqrt(disc)) / aa, floorDist);
const int STEPS = 72;
float dt = (leave - enter) / float(STEPS);
float dist = enter + dt * hash31(float3(uv * u.resolution, 3.7));
float light = 0.0;
for (int i = 0; i < STEPS; i++) {
if (dist > leave) break;
float3 pos = ro + rd * dist;
float inside = flameInside(pos, t, p.height, p.turbulence);
// The fire thickens over the first stretch inside its edge, so the edge is soft.
float density = smoothstep(0.0, 0.12, inside);
light += density * dt * 0.6;
dist += dt;
}
level += light;
}
return float4(kiln(level + (p.heat - 0.5) * 0.5), 1.0);
}5 lines from stage 2 that are gone
float flameInside(float3 p, float height) {
⋯
return radius - length(p.xz);
⋯
float light = 0.0;
⋯
if (flameInside(ro + rd * dist, p.height) > 0.0) light += dt * 0.6;
⋯
return float4(kiln(0.05 + light + (p.heat - 0.5) * 0.5), 1.0);Stage 4 · Light that hides what's behind
Real fire is not quite clear. Each step now also keeps track of how much of the light from further along still reaches the camera: the transmittance. It starts at 1, and every step through fire multiplies it by exp(-density * dt * 6 * thickness). Each step's light is added dimmed by the transmittance so far, and once almost nothing gets through, under 1%, the march stops early.
The background is dimmed by it too: level * transmit + light. With thickness low the flame is glassy and bright all through. Turn it up and the front of the fire hides the back, and the flame looks denser and darker.
stage-4-absorption.metal5 new lines since stage 3
// Stage 4 · Light that hides what's behind
// Fire also swallows light. Keep how much still gets through, and dim each new step's light, and the background, by it.
// @param speed 0.5 0.0 2.0 How fast the fire rises and the camera sways
// @param height 1.0 0.5 1.6 How tall the flame stands
// @param turbulence 0.6 0.0 1.0 How much the noise tears at the flame's edge
// @param thickness 0.5 0.1 1.0 How much light the flame swallows: thin and glassy, or dense and solid
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
// A random number from 0 to 1 for each whole-numbered point in 3D, mixed with integer arithmetic so neighbouring
// cubes look unrelated.
float hash31(float3 p) {
uint3 v = uint3(int3(floor(p))) * 1664525u + 1013904223u;
v.x += v.y * v.z; v.y += v.z * v.x; v.z += v.x * v.y;
v ^= v >> 16u;
v.x += v.y * v.z; v.y += v.z * v.x; v.z += v.x * v.y;
return float(v.x) * (1.0 / 4294967295.0);
}
// Value noise in 3D: a random value at each corner of a grid of cubes, blended smoothly across each cube.
float vnoise3(float3 p) {
float3 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash31(i), b = hash31(i + float3(1, 0, 0));
float c = hash31(i + float3(0, 1, 0)), d = hash31(i + float3(1, 1, 0));
float e = hash31(i + float3(0, 0, 1)), g = hash31(i + float3(1, 0, 1));
float h = hash31(i + float3(0, 1, 1)), k = hash31(i + float3(1, 1, 1));
float lo = mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
float hi = mix(mix(e, g, f.x), mix(h, k, f.x), f.y);
return mix(lo, hi, f.z);
}
float fbm3(float3 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; i++) {
v += a * vnoise3(p);
p = p * 2.02 + float3(17.1, 9.2, 4.7);
a *= 0.5;
}
return v;
}
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 noise rises: as time goes on, each point looks up lower in the noise, so the shapes move up.
float riseNoise(float3 p, float t) {
float3 q = p * float3(3.0, 1.8, 3.0) - float3(0.0, t * 3.0, 0.0);
q.xz = rot(q.xz, p.y * 0.8);
return fbm3(q);
}
// The flame's edge, torn by noise. Two slow noises sway it from side to side, more the higher up, and the rising
// noise pushes its edge in and out, so the top tears into tongues.
float flameInside(float3 p, float t, float height, float turbulence) {
float h = clamp(p.y / (2.6 * height), 0.0, 1.0);
float3 s = float3(p.x, p.y - t * 1.5, p.z) * 0.9;
float2 sway = float2(vnoise3(s), vnoise3(s + float3(5.2, 1.3, 8.1)));
sway -= 0.5;
float2 xz = p.xz + sway * h * 1.4 * turbulence;
float root = smoothstep(-0.05, 0.15, p.y / height);
float radius = 0.7 * pow(1.0 - h, 0.9) * root;
float n = riseNoise(p, t) - 0.5;
return radius - length(xz) + n * turbulence * (0.45 + 1.6 * h);
}
// 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;
float a = 0.35 * sin(t * 0.21);
float back = 2.6 + 0.8 * p.height;
float3 target = float3(0.0, 0.25 + 0.85 * p.height, 0.0);
float3 ro = float3(back * sin(a), 0.6 + 0.6 * p.height, back * cos(a));
float3 fw = normalize(target - ro);
float3 rt = normalize(cross(fw, float3(0.0, 1.0, 0.0)));
float3 up = cross(rt, fw);
float3 rd = normalize(q.x * rt + q.y * up + 2.0 * fw);
float level = 0.05;
float floorDist = rd.y < 0.0 ? -ro.y / rd.y : 1e9;
// Where the ray enters and leaves a cylinder of radius 1.4 around the flame: the only stretch worth marching.
float aa = dot(rd.xz, rd.xz);
float b = dot(ro.xz, rd.xz);
float c = dot(ro.xz, ro.xz) - 1.4 * 1.4;
float disc = b * b - aa * c;
if (disc > 0.0) {
float enter = max((-b - sqrt(disc)) / aa, 0.0);
float leave = min((-b + sqrt(disc)) / aa, floorDist);
const int STEPS = 72;
float dt = (leave - enter) / float(STEPS);
float dist = enter + dt * hash31(float3(uv * u.resolution, 3.7));
// The transmittance: how much of the light from further along still reaches the camera. It starts at 1, and
// each step through fire multiplies it by exp(-density * length * thickness), so it falls off smoothly.
float transmit = 1.0;
float light = 0.0;
for (int i = 0; i < STEPS; i++) {
if (dist > leave || transmit < 0.01) break;
float3 pos = ro + rd * dist;
float inside = flameInside(pos, t, p.height, p.turbulence);
float density = smoothstep(0.0, 0.12, inside);
light += transmit * density * dt * 1.2;
transmit *= exp(-density * dt * 6.0 * p.thickness);
dist += dt;
}
// Whatever lies behind the fire shows only as much as gets through it.
level = level * transmit + light;
}
return float4(kiln(level + (p.heat - 0.5) * 0.5), 1.0);
}3 lines from stage 3 that are gone
if (dist > leave) break;
⋯
light += density * dt * 0.6;
⋯
level += light;Stage 5 · Kiln fire
The last step makes it hot, and gives it somewhere to stand.
- Temperature. Fire is hottest deep inside and near its root.
tempgrows with how far inside the point is and how low it is, and the light a step gives off goes withtemp * temp, so the hot core gives off far more than the edges and the tips. Everything adds up to one level that picks a colour from thekilnpalette at the end, as in episode 3: thin, cool fire is ember red, and where a ray crosses a lot of hot fire it turns gold and white. - The floor. Where the ray meets the floor, it is lit by the fire, brightest under its root and fading with the square of the distance.
glowsets how bright. - Flicker.
flickerreads the same rising noise at one point, the middle of the root, so it rises and falls as the noise flows past. The floor's light follows it, and so does the sound.
height makes the flame taller, and the camera steps back and up to keep it in view. heat slides the whole 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;
// Episode 3's camera, looking at the flame from a little above its root and swaying slowly from side to side. It
// stands further back for a taller flame.
float side = min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution / side * 2.0;
float a = 0.35 * sin(t * 0.21);
float back = 2.6 + 0.8 * p.height;
float3 target = float3(0.0, 0.25 + 0.85 * p.height, 0.0);
float3 ro = float3(back * sin(a), 0.6 + 0.6 * p.height, back * cos(a));
float3 fw = normalize(target - ro);
float3 rt = normalize(cross(fw, float3(0.0, 1.0, 0.0)));
float3 up = cross(rt, fw);
float3 rd = normalize(q.x * rt + q.y * up + 2.0 * fw);
// As in episode 3, everything adds up to one level that picks a colour from the palette at the end.
// Behind everything: the dark of the kiln, a touch warmer low down.
float level = 0.05 + 0.05 * smoothstep(0.25, -0.3, rd.y);
// The floor, y = 0: where the ray meets it, if it does. The march stops there. The fire lights it, brightest at
// the root and fading with distance, and it breathes with the flicker.
float floorDist = rd.y < 0.0 ? -ro.y / rd.y : 1e9;
if (floorDist < 1e8) {
float3 fp = ro + rd * floorDist;
level += p.glow * flicker(t) * 0.9 / (1.0 + 1.5 * dot(fp.xz, fp.xz));
}
// March through the flame's bounds, a cylinder of radius 1.4 around the column, in even steps. Each step adds the
// light the fire gives off there, dimmed by how much fire the ray has already passed through (the transmittance),
// then dims that further. A fixed random start per pixel hides the steps as fine grain instead of bands.
float aa = dot(rd.xz, rd.xz);
float b = dot(ro.xz, rd.xz);
float c = dot(ro.xz, ro.xz) - 1.4 * 1.4;
float disc = b * b - aa * c;
if (disc > 0.0) {
float enter = max((-b - sqrt(disc)) / aa, 0.0);
float leave = min((-b + sqrt(disc)) / aa, floorDist);
const int STEPS = 72;
float dt = (leave - enter) / float(STEPS);
float dist = enter + dt * hash31(float3(uv * u.resolution, 3.7));
float transmit = 1.0;
float light = 0.0;
for (int i = 0; i < STEPS; i++) {
if (dist > leave || transmit < 0.01) break;
float3 pos = ro + rd * dist;
float inside = flameInside(pos, t, p.height, p.turbulence);
if (inside > 0.0) {
// The fire thickens over the first stretch inside its edge.
float density = smoothstep(0.0, 0.12, inside);
// Hottest deep inside and near the root. Hotter fire gives off more light.
float h = clamp(pos.y / (2.6 * p.height), 0.0, 1.0);
float temp = clamp(inside * 2.5 + 0.45 * (1.0 - h), 0.0, 1.0);
light += transmit * density * temp * temp * dt * 3.5;
transmit *= exp(-density * dt * 6.0 * p.thickness);
}
dist += dt;
}
level = level * transmit + light;
}
return float4(kiln(level + (p.heat - 0.5) * 0.5), 1.0);
}episode-08-kiln-fire.metal, the whole file60 new lines since stage 4
// Ray Kiln · Episode 8 · Kiln Fire
//
// @episode 8 Kiln Fire
// @length 60
// @variation 30
// @variations 1,2,3,5,6,7,10,12
// @sweeps turbulence, thickness, height
// @still 20.0
// @teaches Volume raymarching through noise, emission
// @category Fragment
// @tags volume rendering, raymarching, fractal noise, emission, absorption, procedural sound
// @final Kiln fire | Shape the noise into a flame, hottest at its root, rising and flickering over a glowing floor.
//
// A fire with no surface: each pixel's ray walks through a cloud of 3D noise in small steps, and at every step the
// cloud gives off light by how hot it is there and hides a little of what lies behind it.
// 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 fire rises and the camera sways
// @param height 1.0 0.5 1.6 How tall the flame stands
// @param turbulence 0.6 0.0 1.0 How much the noise tears at the flame's edge
// @param thickness 0.5 0.1 1.0 How much light the flame swallows: thin and glassy, or dense and solid
// @param glow 0.5 0.0 1.0 How brightly the flame lights the floor around it
// @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;
// ---- Noise in three dimensions ---------------------------------------------------------------------------------
// Episode 1's value noise, one dimension up: a random value at each corner of a grid of cubes, blended smoothly
// across each cube. Eight corners instead of four.
// A random number from 0 to 1 for each whole-numbered point in 3D, mixed with integer arithmetic so neighbouring
// cubes look unrelated.
float hash31(float3 p) {
uint3 v = uint3(int3(floor(p))) * 1664525u + 1013904223u;
v.x += v.y * v.z; v.y += v.z * v.x; v.z += v.x * v.y;
v ^= v >> 16u;
v.x += v.y * v.z; v.y += v.z * v.x; v.z += v.x * v.y;
return float(v.x) * (1.0 / 4294967295.0);
}
float vnoise3(float3 p) {
float3 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash31(i), b = hash31(i + float3(1, 0, 0));
float c = hash31(i + float3(0, 1, 0)), d = hash31(i + float3(1, 1, 0));
float e = hash31(i + float3(0, 0, 1)), g = hash31(i + float3(1, 0, 1));
float h = hash31(i + float3(0, 1, 1)), k = hash31(i + float3(1, 1, 1));
float lo = mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
float hi = mix(mix(e, g, f.x), mix(h, k, f.x), f.y);
return mix(lo, hi, f.z);
}
// Fractal noise: octaves of vnoise3, each about twice as fine and half as strong. Around 0.5 on average.
float fbm3(float3 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; i++) {
v += a * vnoise3(p);
p = p * 2.02 + float3(17.1, 9.2, 4.7);
a *= 0.5;
}
return v;
}
// 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 flame -------------------------------------------------------------------------------------------------
// The noise rises: the point is looked up lower in the noise as time goes on, so its shapes move up through the
// flame. The sound reads the same noise.
float riseNoise(float3 p, float t) {
float3 q = p * float3(3.0, 1.8, 3.0) - float3(0.0, t * 3.0, 0.0);
q.xz = rot(q.xz, p.y * 0.8);
return fbm3(q);
}
// How far inside the flame a point is: positive inside, negative outside, like a distance with the sign turned.
// The flame is a column that is widest near the floor and narrows to nothing at the top. Two slow noises sway it
// from side to side, more the higher up, and the rising noise pushes its edge in and out, so the top tears into
// tongues.
float flameInside(float3 p, float t, float height, float turbulence) {
float h = clamp(p.y / (2.6 * height), 0.0, 1.0);
float3 s = float3(p.x, p.y - t * 1.5, p.z) * 0.9;
float2 sway = float2(vnoise3(s), vnoise3(s + float3(5.2, 1.3, 8.1)));
sway -= 0.5;
float2 xz = p.xz + sway * h * 1.4 * turbulence;
float root = smoothstep(-0.05, 0.15, p.y / height);
float radius = 0.7 * pow(1.0 - h, 0.9) * root;
float n = riseNoise(p, t) - 0.5;
return radius - length(xz) + n * turbulence * (0.45 + 1.6 * h);
}
// How bright the fire's root is right now, from the noise flowing past it: the floor's light and the roar follow it.
float flicker(float t) {
return 0.7 + 0.9 * (fbm3(float3(0.0, -t * 3.0, 0.0)) - 0.5);
}
// ---- 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;
// Episode 3's camera, looking at the flame from a little above its root and swaying slowly from side to side. It
// stands further back for a taller flame.
float side = min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution / side * 2.0;
float a = 0.35 * sin(t * 0.21);
float back = 2.6 + 0.8 * p.height;
float3 target = float3(0.0, 0.25 + 0.85 * p.height, 0.0);
float3 ro = float3(back * sin(a), 0.6 + 0.6 * p.height, back * cos(a));
float3 fw = normalize(target - ro);
float3 rt = normalize(cross(fw, float3(0.0, 1.0, 0.0)));
float3 up = cross(rt, fw);
float3 rd = normalize(q.x * rt + q.y * up + 2.0 * fw);
// As in episode 3, everything adds up to one level that picks a colour from the palette at the end.
// Behind everything: the dark of the kiln, a touch warmer low down.
float level = 0.05 + 0.05 * smoothstep(0.25, -0.3, rd.y);
// The floor, y = 0: where the ray meets it, if it does. The march stops there. The fire lights it, brightest at
// the root and fading with distance, and it breathes with the flicker.
float floorDist = rd.y < 0.0 ? -ro.y / rd.y : 1e9;
if (floorDist < 1e8) {
float3 fp = ro + rd * floorDist;
level += p.glow * flicker(t) * 0.9 / (1.0 + 1.5 * dot(fp.xz, fp.xz));
}
// March through the flame's bounds, a cylinder of radius 1.4 around the column, in even steps. Each step adds the
// light the fire gives off there, dimmed by how much fire the ray has already passed through (the transmittance),
// then dims that further. A fixed random start per pixel hides the steps as fine grain instead of bands.
float aa = dot(rd.xz, rd.xz);
float b = dot(ro.xz, rd.xz);
float c = dot(ro.xz, ro.xz) - 1.4 * 1.4;
float disc = b * b - aa * c;
if (disc > 0.0) {
float enter = max((-b - sqrt(disc)) / aa, 0.0);
float leave = min((-b + sqrt(disc)) / aa, floorDist);
const int STEPS = 72;
float dt = (leave - enter) / float(STEPS);
float dist = enter + dt * hash31(float3(uv * u.resolution, 3.7));
float transmit = 1.0;
float light = 0.0;
for (int i = 0; i < STEPS; i++) {
if (dist > leave || transmit < 0.01) break;
float3 pos = ro + rd * dist;
float inside = flameInside(pos, t, p.height, p.turbulence);
if (inside > 0.0) {
// The fire thickens over the first stretch inside its edge.
float density = smoothstep(0.0, 0.12, inside);
// Hottest deep inside and near the root. Hotter fire gives off more light.
float h = clamp(pos.y / (2.6 * p.height), 0.0, 1.0);
float temp = clamp(inside * 2.5 + 0.45 * (1.0 - h), 0.0, 1.0);
light += transmit * density * temp * temp * dt * 3.5;
transmit *= exp(-density * dt * 6.0 * p.thickness);
}
dist += dt;
}
level = level * transmit + light;
}
return float4(kiln(level + (p.heat - 0.5) * 0.5), 1.0);
}
// ---- Sound -----------------------------------------------------------------------------------------------------
// A fire sounds like noise, so the sound is built of noise too: a roar of three bands of it, its loudness following
// the same flicker the floor shows, and crackles, short bursts of fine noise at random moments, more of them the more
// turbulent the flame. Under it a low fifth hums, brighter as the flame burns higher.
float hashU(uint n) {
n = (n << 13u) ^ n;
n = n * (n * n * 15731u + 789221u) + 1376312589u;
return float(n & 0x7fffffffu) / float(0x7fffffff);
}
// 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;
}
float2 sound(float t, constant Params& p) {
float ts = t * p.speed;
float fl = flicker(ts);
// The roar: low, middle and high noise, the same octaves-of-noise idea as fbm3, but in time. Each ear gets its
// own noise, so the fire sounds wide.
float2 roar = float2(0.0);
for (int ch = 0; ch < 2; ch++) {
uint s = uint(ch) * 31u;
float r = 0.6 * noise1(t, 140.0, s + 1u) + 0.3 * noise1(t, 420.0, s + 2u) + 0.12 * noise1(t, 1300.0, s + 3u);
roar[ch] = r;
}
roar *= 0.32 * (0.35 + 0.9 * fl) * (0.5 + 0.5 * p.height);
// Crackles: twenty slots a second, each with a chance of a burst, at a random moment inside its slot.
float2 crackle = float2(0.0);
const float slot = 0.05;
float chance = 0.08 + 0.3 * p.turbulence;
float now = floor(t / slot);
for (int back = 0; back < 3; back++) {
float idx = now - float(back);
if (idx < 0.0) continue;
uint s = uint(idx);
if (hashU(s * 5u) > chance) continue;
float age = t - (idx + 0.8 * hashU(s * 5u + 1u)) * slot;
if (age < 0.0) continue;
float env = (1.0 - exp(-age * 3000.0)) * exp(-age * (90.0 + 120.0 * hashU(s * 5u + 2u)));
float grit = noise1(t, 5000.0 + 6000.0 * hashU(s * 5u + 3u), s);
float pan = 0.15 + 0.7 * hashU(s * 5u + 4u);
float loud = 0.4 + 0.6 * hashU(s * 5u + 5u);
crackle += float2(1.0 - pan, pan) * grit * env * loud * 0.9;
}
// The hum: A1 and the E above it, a few cents apart in each ear, its overtones growing with the flicker.
float bright = 0.2 + 0.5 * clamp(fl, 0.0, 1.2);
float2 hum = float2(sin(TAU * 55.0 * 1.002 * t) + 0.6 * sin(TAU * 82.41 * t) + bright * 0.4 * sin(TAU * 110.0 * t),
sin(TAU * 55.0 * 0.998 * t) + 0.6 * sin(TAU * 82.41 * t + 0.4) + bright * 0.4 * sin(TAU * 110.0 * t + 0.8))
* 0.09;
float fadeIn = smoothstep(0.0, 2.0, t);
return tanh((roar + crackle + hum) * 2.0) * 0.58 * fadeIn * p.volume;
}2 lines from stage 4 that are gone
float level = 0.05;
⋯
light += transmit * density * dt * 1.2;The sound
A fire sounds like noise, so the sound is built from noise too, in three parts.
- The roar. Three bands of episode 5's sound noise, random values joined 140, 420 and 1300 times a second, added at falling strengths, the same octaves idea as
fbm3, in time instead of space. Each ear gets its own noise, so the fire sounds wide. Its loudness followsflicker, the same value that lights the floor, and it grows withheight. - Crackles. The second is split into twenty slots. Each slot has a chance of a crackle, 8% plus up to 30% more with
turbulence, at a random moment inside it: a burst of very fine noise that starts in a fraction of a millisecond and dies away within a few hundredths of a second, at a random loudness, heard from a random place between the ears. Everything about a slot is picked by hashing its number, so the sound is the same every time it plays. - A hum. Under it, a low A at 55 Hz, a few cents apart in each ear, and the E above it, with the A's octave growing brighter as the flicker rises.
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
turbulenceto 0 to see the smooth cone underneath, then raise it slowly and watch the tongues tear away from the top. - Set
thicknessto 0.1 for a glassy flame you can see right through, then to 1 for a dense one. - Set
heightto 1.6 andturbulenceto 1 for a tall, wild fire. - Change
* 3.0inriseNoiseto* 6.0for a fire that burns faster without speeding up the camera. Change it inflickertoo, so the floor and the sound keep up. - Change the light a step gives off from
temp * tempto plaintemp, and see how much more the cool edges glow.
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 fire rises and the camera sways |
height | 1.0 | 0.5 to 1.6 | How tall the flame stands |
turbulence | 0.6 | 0.0 to 1.0 | How much the noise tears at the flame's edge |
thickness | 0.5 | 0.1 to 1.0 | How much light the flame swallows: thin and glassy, or dense and solid |
glow | 0.5 | 0.0 to 1.0 | How brightly the flame lights the floor around it |
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-08-kiln-fire.metal
// Ray Kiln · Episode 8 · Kiln Fire
//
// @episode 8 Kiln Fire
// @length 60
// @variation 30
// @variations 1,2,3,5,6,7,10,12
// @sweeps turbulence, thickness, height
// @still 20.0
// @teaches Volume raymarching through noise, emission
// @category Fragment
// @tags volume rendering, raymarching, fractal noise, emission, absorption, procedural sound
// @final Kiln fire | Shape the noise into a flame, hottest at its root, rising and flickering over a glowing floor.
//
// A fire with no surface: each pixel's ray walks through a cloud of 3D noise in small steps, and at every step the
// cloud gives off light by how hot it is there and hides a little of what lies behind it.
// 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 fire rises and the camera sways
// @param height 1.0 0.5 1.6 How tall the flame stands
// @param turbulence 0.6 0.0 1.0 How much the noise tears at the flame's edge
// @param thickness 0.5 0.1 1.0 How much light the flame swallows: thin and glassy, or dense and solid
// @param glow 0.5 0.0 1.0 How brightly the flame lights the floor around it
// @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;
// ---- Noise in three dimensions ---------------------------------------------------------------------------------
// Episode 1's value noise, one dimension up: a random value at each corner of a grid of cubes, blended smoothly
// across each cube. Eight corners instead of four.
// A random number from 0 to 1 for each whole-numbered point in 3D, mixed with integer arithmetic so neighbouring
// cubes look unrelated.
float hash31(float3 p) {
uint3 v = uint3(int3(floor(p))) * 1664525u + 1013904223u;
v.x += v.y * v.z; v.y += v.z * v.x; v.z += v.x * v.y;
v ^= v >> 16u;
v.x += v.y * v.z; v.y += v.z * v.x; v.z += v.x * v.y;
return float(v.x) * (1.0 / 4294967295.0);
}
float vnoise3(float3 p) {
float3 i = floor(p), f = fract(p);
f = f * f * (3.0 - 2.0 * f);
float a = hash31(i), b = hash31(i + float3(1, 0, 0));
float c = hash31(i + float3(0, 1, 0)), d = hash31(i + float3(1, 1, 0));
float e = hash31(i + float3(0, 0, 1)), g = hash31(i + float3(1, 0, 1));
float h = hash31(i + float3(0, 1, 1)), k = hash31(i + float3(1, 1, 1));
float lo = mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
float hi = mix(mix(e, g, f.x), mix(h, k, f.x), f.y);
return mix(lo, hi, f.z);
}
// Fractal noise: octaves of vnoise3, each about twice as fine and half as strong. Around 0.5 on average.
float fbm3(float3 p) {
float v = 0.0, a = 0.5;
for (int i = 0; i < 5; i++) {
v += a * vnoise3(p);
p = p * 2.02 + float3(17.1, 9.2, 4.7);
a *= 0.5;
}
return v;
}
// 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 flame -------------------------------------------------------------------------------------------------
// The noise rises: the point is looked up lower in the noise as time goes on, so its shapes move up through the
// flame. The sound reads the same noise.
float riseNoise(float3 p, float t) {
float3 q = p * float3(3.0, 1.8, 3.0) - float3(0.0, t * 3.0, 0.0);
q.xz = rot(q.xz, p.y * 0.8);
return fbm3(q);
}
// How far inside the flame a point is: positive inside, negative outside, like a distance with the sign turned.
// The flame is a column that is widest near the floor and narrows to nothing at the top. Two slow noises sway it
// from side to side, more the higher up, and the rising noise pushes its edge in and out, so the top tears into
// tongues.
float flameInside(float3 p, float t, float height, float turbulence) {
float h = clamp(p.y / (2.6 * height), 0.0, 1.0);
float3 s = float3(p.x, p.y - t * 1.5, p.z) * 0.9;
float2 sway = float2(vnoise3(s), vnoise3(s + float3(5.2, 1.3, 8.1)));
sway -= 0.5;
float2 xz = p.xz + sway * h * 1.4 * turbulence;
float root = smoothstep(-0.05, 0.15, p.y / height);
float radius = 0.7 * pow(1.0 - h, 0.9) * root;
float n = riseNoise(p, t) - 0.5;
return radius - length(xz) + n * turbulence * (0.45 + 1.6 * h);
}
// How bright the fire's root is right now, from the noise flowing past it: the floor's light and the roar follow it.
float flicker(float t) {
return 0.7 + 0.9 * (fbm3(float3(0.0, -t * 3.0, 0.0)) - 0.5);
}
// ---- 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;
// Episode 3's camera, looking at the flame from a little above its root and swaying slowly from side to side. It
// stands further back for a taller flame.
float side = min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution / side * 2.0;
float a = 0.35 * sin(t * 0.21);
float back = 2.6 + 0.8 * p.height;
float3 target = float3(0.0, 0.25 + 0.85 * p.height, 0.0);
float3 ro = float3(back * sin(a), 0.6 + 0.6 * p.height, back * cos(a));
float3 fw = normalize(target - ro);
float3 rt = normalize(cross(fw, float3(0.0, 1.0, 0.0)));
float3 up = cross(rt, fw);
float3 rd = normalize(q.x * rt + q.y * up + 2.0 * fw);
// As in episode 3, everything adds up to one level that picks a colour from the palette at the end.
// Behind everything: the dark of the kiln, a touch warmer low down.
float level = 0.05 + 0.05 * smoothstep(0.25, -0.3, rd.y);
// The floor, y = 0: where the ray meets it, if it does. The march stops there. The fire lights it, brightest at
// the root and fading with distance, and it breathes with the flicker.
float floorDist = rd.y < 0.0 ? -ro.y / rd.y : 1e9;
if (floorDist < 1e8) {
float3 fp = ro + rd * floorDist;
level += p.glow * flicker(t) * 0.9 / (1.0 + 1.5 * dot(fp.xz, fp.xz));
}
// March through the flame's bounds, a cylinder of radius 1.4 around the column, in even steps. Each step adds the
// light the fire gives off there, dimmed by how much fire the ray has already passed through (the transmittance),
// then dims that further. A fixed random start per pixel hides the steps as fine grain instead of bands.
float aa = dot(rd.xz, rd.xz);
float b = dot(ro.xz, rd.xz);
float c = dot(ro.xz, ro.xz) - 1.4 * 1.4;
float disc = b * b - aa * c;
if (disc > 0.0) {
float enter = max((-b - sqrt(disc)) / aa, 0.0);
float leave = min((-b + sqrt(disc)) / aa, floorDist);
const int STEPS = 72;
float dt = (leave - enter) / float(STEPS);
float dist = enter + dt * hash31(float3(uv * u.resolution, 3.7));
float transmit = 1.0;
float light = 0.0;
for (int i = 0; i < STEPS; i++) {
if (dist > leave || transmit < 0.01) break;
float3 pos = ro + rd * dist;
float inside = flameInside(pos, t, p.height, p.turbulence);
if (inside > 0.0) {
// The fire thickens over the first stretch inside its edge.
float density = smoothstep(0.0, 0.12, inside);
// Hottest deep inside and near the root. Hotter fire gives off more light.
float h = clamp(pos.y / (2.6 * p.height), 0.0, 1.0);
float temp = clamp(inside * 2.5 + 0.45 * (1.0 - h), 0.0, 1.0);
light += transmit * density * temp * temp * dt * 3.5;
transmit *= exp(-density * dt * 6.0 * p.thickness);
}
dist += dt;
}
level = level * transmit + light;
}
return float4(kiln(level + (p.heat - 0.5) * 0.5), 1.0);
}
// ---- Sound -----------------------------------------------------------------------------------------------------
// A fire sounds like noise, so the sound is built of noise too: a roar of three bands of it, its loudness following
// the same flicker the floor shows, and crackles, short bursts of fine noise at random moments, more of them the more
// turbulent the flame. Under it a low fifth hums, brighter as the flame burns higher.
float hashU(uint n) {
n = (n << 13u) ^ n;
n = n * (n * n * 15731u + 789221u) + 1376312589u;
return float(n & 0x7fffffffu) / float(0x7fffffff);
}
// 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;
}
float2 sound(float t, constant Params& p) {
float ts = t * p.speed;
float fl = flicker(ts);
// The roar: low, middle and high noise, the same octaves-of-noise idea as fbm3, but in time. Each ear gets its
// own noise, so the fire sounds wide.
float2 roar = float2(0.0);
for (int ch = 0; ch < 2; ch++) {
uint s = uint(ch) * 31u;
float r = 0.6 * noise1(t, 140.0, s + 1u) + 0.3 * noise1(t, 420.0, s + 2u) + 0.12 * noise1(t, 1300.0, s + 3u);
roar[ch] = r;
}
roar *= 0.32 * (0.35 + 0.9 * fl) * (0.5 + 0.5 * p.height);
// Crackles: twenty slots a second, each with a chance of a burst, at a random moment inside its slot.
float2 crackle = float2(0.0);
const float slot = 0.05;
float chance = 0.08 + 0.3 * p.turbulence;
float now = floor(t / slot);
for (int back = 0; back < 3; back++) {
float idx = now - float(back);
if (idx < 0.0) continue;
uint s = uint(idx);
if (hashU(s * 5u) > chance) continue;
float age = t - (idx + 0.8 * hashU(s * 5u + 1u)) * slot;
if (age < 0.0) continue;
float env = (1.0 - exp(-age * 3000.0)) * exp(-age * (90.0 + 120.0 * hashU(s * 5u + 2u)));
float grit = noise1(t, 5000.0 + 6000.0 * hashU(s * 5u + 3u), s);
float pan = 0.15 + 0.7 * hashU(s * 5u + 4u);
float loud = 0.4 + 0.6 * hashU(s * 5u + 5u);
crackle += float2(1.0 - pan, pan) * grit * env * loud * 0.9;
}
// The hum: A1 and the E above it, a few cents apart in each ear, its overtones growing with the flicker.
float bright = 0.2 + 0.5 * clamp(fl, 0.0, 1.2);
float2 hum = float2(sin(TAU * 55.0 * 1.002 * t) + 0.6 * sin(TAU * 82.41 * t) + bright * 0.4 * sin(TAU * 110.0 * t),
sin(TAU * 55.0 * 0.998 * t) + 0.6 * sin(TAU * 82.41 * t + 0.4) + bright * 0.4 * sin(TAU * 110.0 * t + 0.8))
* 0.09;
float fadeIn = smoothstep(0.0, 2.0, t);
return tanh((roar + crackle + hum) * 2.0) * 0.58 * fadeIn * p.volume;
}Running it in your own project
Every Ray Kiln shader is the same shape. The standalone download above is the shader with the small wrapper around it that the Ray Kiln host adds, so it compiles with the ordinary Metal compiler and runs in your own app. It defines three entry points: rk_vertex (a full-screen triangle), rk_fragment (calls the shader's shade) and, for shaders with sound, the compute kernel rk_sound.
To draw it, pass the uniforms at fragment buffer 0 and the parameter values at buffer 1, in the order they are declared:
Drawing it, in Swift
struct Uniforms {
var resolution: SIMD2<Float>
var time: Float
var timeDelta: Float
var mouse: SIMD2<Float>
var frame: UInt32
var pad: UInt32 = 0
}
let library = try device.makeLibrary(source: standaloneSource, options: nil)
let descriptor = MTLRenderPipelineDescriptor()
descriptor.vertexFunction = library.makeFunction(name: "rk_vertex")
descriptor.fragmentFunction = library.makeFunction(name: "rk_fragment")
descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
let pipeline = try device.makeRenderPipelineState(descriptor: descriptor)
// Each frame, inside a render pass:
var uniforms = Uniforms(resolution: size, time: time, timeDelta: dt, mouse: mouse, frame: frame)
var params: [Float] = [0.5, 1.0, 0.6, 0.5, 0.5, 0.5, 0.8] // the @param defaults, in the order they are declared
encoder.setRenderPipelineState(pipeline)
encoder.setFragmentBytes(&uniforms, length: MemoryLayout<Uniforms>.stride, index: 0)
encoder.setFragmentBytes(¶ms, length: params.count * MemoryLayout<Float>.stride, index: 1)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)Time is in seconds, mouse is in pixels from the bottom left, and uv in the shader runs 0 to 1 with (0, 0) at the bottom left. Write the output as sRGB: the shader's numbers go to the screen as they are.
More in 3D Scenes
Press ? for keyboard shortcuts.

