Molten Metaballs
Blobs of molten metal drifting over a kiln floor and melting into each other wherever they meet, with soft-edged shadows, creases darkened by ambient occlusion, and a sound in which every blob sings as it starts to melt.
Teaches 3D smooth blending, soft shadows, ambient occlusion
Watch
The idea
Episode 3 marched a ray through every pixel to find a few shapes, then lit them by the way they faced the light. This time the shapes are blobs that melt together, like drops of molten metal: the smooth minimum from episode 2, folded over a whole handful of spheres in 3D. Blobs like these are known as metaballs.
Lighting by the way a surface faces only knows about that one point. It can't tell if something else stands between the point and the light, or if the point sits deep in a crease. This episode adds the two oldest tricks for that, and both reuse the distance field the ray already marches through. A soft shadow marches a second ray from the surface towards the light. Ambient occlusion takes a few short steps out from the surface to see how boxed in it is.
It is built in five steps, and each step is a shader you can run on its own.
Stage 1 · Blobs on a floor
The scene is a floor and a handful of spheres. The floor is a flat plane at y = -0.55, and its distance is simply the height above it: p.y - FLOOR. map takes the nearest of the floor and every sphere with a plain min, so for now the shapes just pass through each other.
ballPos says where blob i is at time t. Each blob circles the middle at its own speed, its distance from the middle swells and shrinks, and it bobs up and down. The numbers in each formula depend on i, so no two blobs move alike. balls sets how many there are, rounded to a whole number, and rise sets how far they bob. At the default the lowest blobs dip below the floor.
The camera circles the scene from above and looks down at the middle of the floor, set up the way it was in episode 3. The light is episode 3's diffuse light: a surface is as bright as dot(nrm, l), with a little light added everywhere. Far away the floor fades to black, so it seems to go on into the dark.
stage-1-blobs.metal
// Stage 1 · Blobs on a floor
// A handful of spheres drift above a flat floor. The camera and the light work as in episode 3.
// @param speed 0.5 0.0 2.0 How fast the blobs drift and the camera circles
// @param balls 5.0 2.0 7.0 How many blobs there are
// @param rise 1.0 0.0 1.5 How high the blobs float up and how deep they sink into the floor
// @param light 0.15 0.0 1.0 Where the light stands, once around the scene
float sdSphere(float3 p, float r) {
return length(p) - r;
}
// Where blob i is at time t. Each drifts around the middle on its own slow orbit, and bobs up and down.
float3 ballPos(int i, float t, float rise) {
float fi = float(i);
float a = t * (0.30 + 0.07 * fi) + fi * 2.4;
float r = 0.9 + 0.45 * sin(t * (0.23 + 0.05 * fi) + fi * 2.1);
float y = 0.15 + rise * 0.5 * sin(t * (0.55 + 0.11 * fi) + fi * 1.7);
return float3(r * cos(a), y, r * sin(a));
}
float ballRadius(int i) {
return 0.34 + 0.08 * sin(float(i) * 3.7);
}
constant float FLOOR = -0.55;
// The scene: the nearest of the blobs and the floor, a flat plane below them. Its distance is simply the height.
float map(float3 p, float t, int n, float rise, float k) {
float d = p.y - FLOOR;
for (int i = 0; i < n; i++) {
d = min(d, sdSphere(p - ballPos(i, t, rise), ballRadius(i)));
}
return d;
}
// The normal, as in episode 3: how the distance changes for a small step along each axis.
float3 normalAt(float3 p, float t, int n, float rise, float k) {
const float2 e = float2(0.001, 0.0);
return normalize(float3(
map(p + e.xyy, t, n, rise, k) - map(p - e.xyy, t, n, rise, k),
map(p + e.yxy, t, n, rise, k) - map(p - e.yxy, t, n, rise, k),
map(p + e.yyx, t, n, rise, k) - map(p - e.yyx, t, n, rise, k)));
}
// The camera circles the scene slowly, above it, looking down at the middle of the floor.
float3 cameraPos(float t) {
float a = t * 0.2;
return float3(3.4 * sin(a), 1.6, 3.4 * cos(a));
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float t = u.time * p.speed;
int n = int(clamp(round(p.balls), 1.0, 7.0));
float k = 0.0;
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.1, 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.0 * fw);
float dist = 0.0;
bool hit = false;
for (int i = 0; i < 128; i++) {
float d = map(ro + rd * dist, t, n, p.rise, k);
if (d < 0.001 * dist) { hit = true; break; }
dist += d;
if (dist > 20.0) break;
}
if (!hit) return float4(0.0, 0.0, 0.0, 1.0);
float3 pos = ro + rd * dist;
float3 nrm = normalAt(pos, t, n, p.rise, k);
float a = p.light * 6.2831853;
float3 l = normalize(float3(cos(a), 1.4, sin(a)));
float diffuse = max(dot(nrm, l), 0.0);
// A little light everywhere, and the rest as the surface faces the light.
float level = 0.1 + 0.8 * diffuse;
// Far away the floor fades into the dark.
level *= 1.0 - smoothstep(3.5, 9.0, dist);
return float4(float3(level), 1.0);
}Stage 2 · Melting together
smin is the smooth minimum from episode 2. When two distances are within k of each other, it pulls the result below both of them, most of all when they are equal. A surface is where the distance is zero, so pulling the distance down pushes the surface out. Where two spheres come close, the gap between them fills in with a smooth neck.
blobs folds smin over every sphere, one at a time. It starts from a huge distance, 1e9, so the first sphere is taken as it is. map then melts the blobs into the floor as well, with a smaller k, so a blob that sinks low pools into the floor instead of cutting into it. melt sets k: at 0 the blobs barely touch before they join, and at 1 they reach out to each other from far apart. k is never quite zero, because smin divides by it.
stage-2-melt.metal
// Stage 2 · Melting together
// The smooth minimum, folded over every blob and the floor, lets the blobs melt into one another as they meet.
// @param speed 0.5 0.0 2.0 How fast the blobs drift and the camera circles
// @param balls 5.0 2.0 7.0 How many blobs there are
// @param melt 0.5 0.0 1.0 How far apart blobs start to melt into each other and into the floor
// @param rise 1.0 0.0 1.5 How high the blobs float up and how deep they sink into the floor
// @param light 0.15 0.0 1.0 Where the light stands, once around the scene
float sdSphere(float3 p, float r) {
return length(p) - r;
}
// Episode 2's smooth minimum: within k of each other, two distances are pulled down together.
float smin(float a, float b, float k) {
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * k * 0.25;
}
// Where blob i is at time t. Each drifts around the middle on its own slow orbit, and bobs up and down.
float3 ballPos(int i, float t, float rise) {
float fi = float(i);
float a = t * (0.30 + 0.07 * fi) + fi * 2.4;
float r = 0.9 + 0.45 * sin(t * (0.23 + 0.05 * fi) + fi * 2.1);
float y = 0.15 + rise * 0.5 * sin(t * (0.55 + 0.11 * fi) + fi * 1.7);
return float3(r * cos(a), y, r * sin(a));
}
float ballRadius(int i) {
return 0.34 + 0.08 * sin(float(i) * 3.7);
}
constant float FLOOR = -0.55;
// The blobs melt into each other: fold the smooth minimum over them, one at a time.
float blobs(float3 p, float t, int n, float rise, float k) {
float d = 1e9;
for (int i = 0; i < n; i++) {
d = smin(d, sdSphere(p - ballPos(i, t, rise), ballRadius(i)), k);
}
return d;
}
// Then the blobs melt into the floor too, a little more tightly, so they pool where they touch it.
float map(float3 p, float t, int n, float rise, float k) {
return smin(blobs(p, t, n, rise, k), p.y - FLOOR, k * 0.6);
}
// The normal, as in episode 3: how the distance changes for a small step along each axis.
float3 normalAt(float3 p, float t, int n, float rise, float k) {
const float2 e = float2(0.001, 0.0);
return normalize(float3(
map(p + e.xyy, t, n, rise, k) - map(p - e.xyy, t, n, rise, k),
map(p + e.yxy, t, n, rise, k) - map(p - e.yxy, t, n, rise, k),
map(p + e.yyx, t, n, rise, k) - map(p - e.yyx, t, n, rise, k)));
}
// The camera circles the scene slowly, above it, looking down at the middle of the floor.
float3 cameraPos(float t) {
float a = t * 0.2;
return float3(3.4 * sin(a), 1.6, 3.4 * cos(a));
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float t = u.time * p.speed;
int n = int(clamp(round(p.balls), 1.0, 7.0));
float k = max(p.melt * 0.9, 0.0001);
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.1, 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.0 * fw);
float dist = 0.0;
bool hit = false;
for (int i = 0; i < 128; i++) {
float d = map(ro + rd * dist, t, n, p.rise, k);
if (d < 0.001 * dist) { hit = true; break; }
dist += d;
if (dist > 20.0) break;
}
if (!hit) return float4(0.0, 0.0, 0.0, 1.0);
float3 pos = ro + rd * dist;
float3 nrm = normalAt(pos, t, n, p.rise, k);
float a = p.light * 6.2831853;
float3 l = normalize(float3(cos(a), 1.4, sin(a)));
float diffuse = max(dot(nrm, l), 0.0);
float level = 0.1 + 0.8 * diffuse;
// Far away the floor fades into the dark.
level *= 1.0 - smoothstep(3.5, 9.0, dist);
return float4(float3(level), 1.0);
}Stage 3 · Soft shadows
To know if a point is in shadow, march again: from the surface towards the light, with the same map. The ray starts a hair above the surface (pos + nrm * 0.01) so it doesn't hit the point it started from. If it meets something on the way, the point is in shadow.
That alone gives hard shadows. Soft shadows come from one more number. At every step, h / (w * s) compares how close the ray passes to something (h) with how far it has gone (s). A ray that just grazes the edge of a blob passes close, but not through. It counts as partly shaded, and the nearer it passes the darker it gets, so the shadow fades out over a band. This is the penumbra a real light makes, because a real light is not a single point. The smallest value along the ray is kept, and a smoothstep rounds it off. soft sets w: near 0 the band shrinks to a hard line, and at 1 the shadows blur widely. The march gives up after 48 steps or 6 units, and each step is at least 0.01, so a ray that skims along a surface still moves on.
stage-3-shadows.metal
// Stage 3 · Soft shadows
// A second march, from the surface towards the light, finds what stands in the way, and how nearly.
// @param speed 0.5 0.0 2.0 How fast the blobs drift and the camera circles
// @param balls 5.0 2.0 7.0 How many blobs there are
// @param melt 0.5 0.0 1.0 How far apart blobs start to melt into each other and into the floor
// @param rise 1.0 0.0 1.5 How high the blobs float up and how deep they sink into the floor
// @param soft 0.5 0.0 1.0 How soft the shadows are at their edges: 0 is a hard edge
// @param light 0.15 0.0 1.0 Where the light stands, once around the scene
float sdSphere(float3 p, float r) {
return length(p) - r;
}
// Episode 2's smooth minimum: within k of each other, two distances are pulled down together.
float smin(float a, float b, float k) {
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * k * 0.25;
}
// Where blob i is at time t. Each drifts around the middle on its own slow orbit, and bobs up and down.
float3 ballPos(int i, float t, float rise) {
float fi = float(i);
float a = t * (0.30 + 0.07 * fi) + fi * 2.4;
float r = 0.9 + 0.45 * sin(t * (0.23 + 0.05 * fi) + fi * 2.1);
float y = 0.15 + rise * 0.5 * sin(t * (0.55 + 0.11 * fi) + fi * 1.7);
return float3(r * cos(a), y, r * sin(a));
}
float ballRadius(int i) {
return 0.34 + 0.08 * sin(float(i) * 3.7);
}
constant float FLOOR = -0.55;
// The blobs melt into each other: fold the smooth minimum over them, one at a time.
float blobs(float3 p, float t, int n, float rise, float k) {
float d = 1e9;
for (int i = 0; i < n; i++) {
d = smin(d, sdSphere(p - ballPos(i, t, rise), ballRadius(i)), k);
}
return d;
}
// Then the blobs melt into the floor too, a little more tightly, so they pool where they touch it.
float map(float3 p, float t, int n, float rise, float k) {
return smin(blobs(p, t, n, rise, k), p.y - FLOOR, k * 0.6);
}
// The normal, as in episode 3: how the distance changes for a small step along each axis.
float3 normalAt(float3 p, float t, int n, float rise, float k) {
const float2 e = float2(0.001, 0.0);
return normalize(float3(
map(p + e.xyy, t, n, rise, k) - map(p - e.xyy, t, n, rise, k),
map(p + e.yxy, t, n, rise, k) - map(p - e.yxy, t, n, rise, k),
map(p + e.yyx, t, n, rise, k) - map(p - e.yyx, t, n, rise, k)));
}
// A soft shadow: march from the surface towards the light. The nearer the ray passes to something, compared with how
// far it has gone, the deeper the shadow, so a shadow fades out over a band instead of stopping at a line.
float softShadow(float3 ro, float3 rd, float w,
float t, int n, float rise, float k) {
float res = 1.0;
float s = 0.02;
for (int i = 0; i < 48; i++) {
float h = map(ro + rd * s, t, n, rise, k);
res = min(res, h / (w * s));
if (res < 0.001 || s > 6.0) break;
s += clamp(h, 0.01, 0.3);
}
res = clamp(res, 0.0, 1.0);
return res * res * (3.0 - 2.0 * res);
}
// The camera circles the scene slowly, above it, looking down at the middle of the floor.
float3 cameraPos(float t) {
float a = t * 0.2;
return float3(3.4 * sin(a), 1.6, 3.4 * cos(a));
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float t = u.time * p.speed;
int n = int(clamp(round(p.balls), 1.0, 7.0));
float k = max(p.melt * 0.9, 0.0001);
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.1, 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.0 * fw);
float dist = 0.0;
bool hit = false;
for (int i = 0; i < 128; i++) {
float d = map(ro + rd * dist, t, n, p.rise, k);
if (d < 0.001 * dist) { hit = true; break; }
dist += d;
if (dist > 20.0) break;
}
if (!hit) return float4(0.0, 0.0, 0.0, 1.0);
float3 pos = ro + rd * dist;
float3 nrm = normalAt(pos, t, n, p.rise, k);
float a = p.light * 6.2831853;
float3 l = normalize(float3(cos(a), 1.4, sin(a)));
float diffuse = max(dot(nrm, l), 0.0);
// The light only reaches what the shadow march says it can.
float w = 0.005 + 0.3 * p.soft;
float shadow = softShadow(pos + nrm * 0.01, l, w, t, n, p.rise, k);
float level = 0.1 + 0.8 * diffuse * shadow;
// Far away the floor fades into the dark.
level *= 1.0 - smoothstep(3.5, 9.0, dist);
return float4(float3(level), 1.0);
}Stage 4 · Ambient occlusion
Not all light comes from the lamp. Light also bounces in from everywhere around, and creases get less of it, because the walls around them block most directions. Ambient occlusion estimates that from the distance field.
From the surface, take five short steps out along the normal, from 0.02 to 0.5 units. On an open surface, the distance at each step equals the step: nothing is nearer than the surface you came from. In a crease, something else is nearer, so the distance comes back smaller than the step. h - map(...) measures how much smaller, and the near steps count most, since each step weighs 0.8 times the one before. The sum darkens the point. You can see it under each blob where it nears the floor, and in the creases where blobs meet.
occlusion sets how much of this is used. From 0 to 1 it fades the darkening in, and above 1 it is raised to a power, so the creases go darker still.
stage-4-occlusion.metal
// Stage 4 · Ambient occlusion
// Creases and corners get less light from all around. A few short steps along the normal measure how much less.
// @param speed 0.5 0.0 2.0 How fast the blobs drift and the camera circles
// @param balls 5.0 2.0 7.0 How many blobs there are
// @param melt 0.5 0.0 1.0 How far apart blobs start to melt into each other and into the floor
// @param rise 1.0 0.0 1.5 How high the blobs float up and how deep they sink into the floor
// @param soft 0.5 0.0 1.0 How soft the shadows are at their edges: 0 is a hard edge
// @param occlusion 1.0 0.0 2.0 How dark the creases get where little light reaches
// @param light 0.15 0.0 1.0 Where the light stands, once around the scene
float sdSphere(float3 p, float r) {
return length(p) - r;
}
// Episode 2's smooth minimum: within k of each other, two distances are pulled down together.
float smin(float a, float b, float k) {
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * k * 0.25;
}
// Where blob i is at time t. Each drifts around the middle on its own slow orbit, and bobs up and down.
float3 ballPos(int i, float t, float rise) {
float fi = float(i);
float a = t * (0.30 + 0.07 * fi) + fi * 2.4;
float r = 0.9 + 0.45 * sin(t * (0.23 + 0.05 * fi) + fi * 2.1);
float y = 0.15 + rise * 0.5 * sin(t * (0.55 + 0.11 * fi) + fi * 1.7);
return float3(r * cos(a), y, r * sin(a));
}
float ballRadius(int i) {
return 0.34 + 0.08 * sin(float(i) * 3.7);
}
constant float FLOOR = -0.55;
// The blobs melt into each other: fold the smooth minimum over them, one at a time.
float blobs(float3 p, float t, int n, float rise, float k) {
float d = 1e9;
for (int i = 0; i < n; i++) {
d = smin(d, sdSphere(p - ballPos(i, t, rise), ballRadius(i)), k);
}
return d;
}
// Then the blobs melt into the floor too, a little more tightly, so they pool where they touch it.
float map(float3 p, float t, int n, float rise, float k) {
return smin(blobs(p, t, n, rise, k), p.y - FLOOR, k * 0.6);
}
// The normal, as in episode 3: how the distance changes for a small step along each axis.
float3 normalAt(float3 p, float t, int n, float rise, float k) {
const float2 e = float2(0.001, 0.0);
return normalize(float3(
map(p + e.xyy, t, n, rise, k) - map(p - e.xyy, t, n, rise, k),
map(p + e.yxy, t, n, rise, k) - map(p - e.yxy, t, n, rise, k),
map(p + e.yyx, t, n, rise, k) - map(p - e.yyx, t, n, rise, k)));
}
// A soft shadow: march from the surface towards the light. The nearer the ray passes to something, compared with how
// far it has gone, the deeper the shadow, so a shadow fades out over a band instead of stopping at a line.
float softShadow(float3 ro, float3 rd, float w,
float t, int n, float rise, float k) {
float res = 1.0;
float s = 0.02;
for (int i = 0; i < 48; i++) {
float h = map(ro + rd * s, t, n, rise, k);
res = min(res, h / (w * s));
if (res < 0.001 || s > 6.0) break;
s += clamp(h, 0.01, 0.3);
}
res = clamp(res, 0.0, 1.0);
return res * res * (3.0 - 2.0 * res);
}
// Ambient occlusion: step out along the normal. Where the scene is nearer than the step, something blocks the light
// from that side, and the nearer steps count most.
float ambientOcclusion(float3 pos, float3 nrm,
float t, int n, float rise, float k) {
float occ = 0.0;
float weight = 1.0;
for (int i = 0; i < 5; i++) {
float h = 0.02 + 0.12 * float(i);
occ += (h - map(pos + nrm * h, t, n, rise, k)) * weight;
weight *= 0.8;
}
return clamp(1.0 - 2.0 * occ, 0.0, 1.0);
}
// The camera circles the scene slowly, above it, looking down at the middle of the floor.
float3 cameraPos(float t) {
float a = t * 0.2;
return float3(3.4 * sin(a), 1.6, 3.4 * cos(a));
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float t = u.time * p.speed;
int n = int(clamp(round(p.balls), 1.0, 7.0));
float k = max(p.melt * 0.9, 0.0001);
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.1, 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.0 * fw);
float dist = 0.0;
bool hit = false;
for (int i = 0; i < 128; i++) {
float d = map(ro + rd * dist, t, n, p.rise, k);
if (d < 0.001 * dist) { hit = true; break; }
dist += d;
if (dist > 20.0) break;
}
if (!hit) return float4(0.0, 0.0, 0.0, 1.0);
float3 pos = ro + rd * dist;
float3 nrm = normalAt(pos, t, n, p.rise, k);
float a = p.light * 6.2831853;
float3 l = normalize(float3(cos(a), 1.4, sin(a)));
float diffuse = max(dot(nrm, l), 0.0);
float w = 0.005 + 0.3 * p.soft;
float shadow = softShadow(pos + nrm * 0.01, l, w, t, n, p.rise, k);
// The occlusion darkens both the light from all around and the direct light; above 1 it darkens more.
float ao = ambientOcclusion(pos, nrm, t, n, p.rise, k);
ao = mix(1.0, ao, clamp(p.occlusion, 0.0, 1.0));
ao = pow(ao, max(p.occlusion, 1.0));
float level = (0.1 + 0.8 * diffuse * shadow) * ao;
// Far away the floor fades into the dark.
level *= 1.0 - smoothstep(3.5, 9.0, dist);
return float4(float3(level), 1.0);
}Stage 5 · Molten
The last step colours the scene with the kiln palette from episode 1. As in episode 3, all the light adds up to one number, level, and the palette turns it into a colour, from deep ember to white-hot. heat slides the whole palette.
- Metal or floor.
blobs(pos)andpos.y - FLOORsay which surface the point is nearer to. The blobs are lit brighter, with a highlight like episode 3's, and the floor is dark clay. Where a blob pools into the floor, the two blend. - Hot necks. The difference between the blobs joined with a plain
min(blobsHard) and withsminis how far the smooth minimum pulled the surface. That pull is largest, a quarter ofk, where two blobs are equally near: the middle of a neck. Where it comes close to that, the metal glows, so the seams where blobs melt together burn white-hot. - A warm floor. The floor gets brighter the nearer it is to a blob, as if the metal's heat reflected onto it.
The shadows and the ambient occlusion from stages 3 and 4 still darken everything, so the glowing blobs sit in pools of their own shadow.
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;
int n = int(clamp(round(p.balls), 1.0, 7.0));
float k = max(p.melt * 0.9, 0.0001);
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.1, 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.0 * fw);
float dist = 0.0;
bool hit = false;
for (int i = 0; i < 128; i++) {
float d = map(ro + rd * dist, t, n, p.rise, k);
if (d < 0.001 * dist) { hit = true; break; }
dist += d;
if (dist > 20.0) break;
}
// Behind everything, the dark of the kiln.
if (!hit) return float4(kiln(0.02 + (p.heat - 0.5) * 0.5), 1.0);
float3 pos = ro + rd * dist;
float3 nrm = normalAt(pos, t, n, p.rise, k);
float a = p.light * 6.2831853;
float3 l = normalize(float3(cos(a), 1.4, sin(a)));
float diffuse = max(dot(nrm, l), 0.0);
float w = 0.005 + 0.3 * p.soft;
float shadow = softShadow(pos + nrm * 0.01, l, w, t, n, p.rise, k);
float ao = ambientOcclusion(pos, nrm, t, n, p.rise, k);
ao = mix(1.0, ao, clamp(p.occlusion, 0.0, 1.0));
ao = pow(ao, max(p.occlusion, 1.0));
// A highlight, as in episode 3.
float3 h = normalize(l - rd);
float spec = pow(max(dot(nrm, h), 0.0), 48.0) * diffuse * shadow;
// On a blob or on the floor? Whichever surface is nearer.
float db = blobs(pos, t, n, p.rise, k);
float metal = smoothstep(0.05, -0.05, db - (pos.y - FLOOR));
// How far smin pulled the surface out: a quarter of k mid-neck.
float pull = (blobsHard(pos, t, n, p.rise) - db) / (k * 0.25);
float neck = smoothstep(0.45, 1.0, pull);
// The floor is dark clay, warmed by the metal above it.
float bounce = exp(-max(db, 0.0) * 3.0) * 0.3;
float floorLevel = (0.04 + 0.30 * diffuse * shadow + bounce) * ao;
// The metal is lit, has a highlight, and glows where it melts.
float metalLevel = (0.24 + 0.40 * diffuse * shadow) * ao;
metalLevel += 0.5 * spec + 0.4 * neck;
float level = mix(floorLevel, metalLevel, metal);
// Far away the floor fades into the dark.
level = mix(level, 0.02, smoothstep(3.5, 9.0, dist));
float3 col = kiln(level + (p.heat - 0.5) * 0.5);
return float4(col, 1.0);
}The sound
The sound is built from the blobs' positions, using the same ballPos as the picture. Under everything, a low E hums, a few cents apart in each ear. It grows brighter, with more overtones, the more the blobs are melting together.
Each blob has a note of E minor pentatonic. A blob is silent while it is far from the others. As it comes within reach of its nearest neighbour, its note swells and brightens, so a chord builds as blobs meet and fades as they part. melt widens the reach, as it does in the picture. Each note is heard from where its blob is on the screen, panned with the camera's right-hand direction the way episode 3's box was.
Above them the metal bubbles. Time is cut into slots of 0.17 seconds, and a hash of each slot's number decides if it gets a bubble, when in the slot it pops, its pitch and where it sits between the ears. Each bubble is a short tone whose pitch rises as it fades, and the higher melt, the more slots bubble. Everything goes through tanh to round off the loud moments. The levels at the defaults are a peak of about -10 dBFS and an average (RMS) of about -20 dBFS.
Try this
- Set
meltto 0 and the blobs are plain spheres again. Raise it slowly and watch the necks reach across the gaps. - Set
softto 0 for hard shadows, then to 1, and watch the shadow's edge spread into a band. - Set
occlusionto 0, then to 2, and look at where the blobs meet the floor. - Set
ballsto 7 andriseto 0, so every blob stays at one height and they crowd into one molten mass. - Add a blob of your own that stays still: in
blobs, after the loop,d = smin(d, sdSphere(p - float3(0.0, 0.6, 0.0), 0.3), k);. Don't forget the sound: it usesballPosonly, so the new blob will be silent.
Parameters
| Parameter | Default | Range | What it does |
|---|---|---|---|
speed | 0.5 | 0.0 to 2.0 | How fast the blobs drift and the camera circles |
balls | 5.0 | 2.0 to 7.0 | How many blobs there are |
melt | 0.5 | 0.0 to 1.0 | How far apart blobs start to melt into each other and into the floor |
rise | 1.0 | 0.0 to 1.5 | How high the blobs float up and how deep they sink into the floor |
soft | 0.5 | 0.0 to 1.0 | How soft the shadows are at their edges: 0 is a hard edge |
occlusion | 1.0 | 0.0 to 2.0 | How dark the creases get where little light reaches |
light | 0.15 | 0.0 to 1.0 | Where the light stands, once around the scene |
heat | 0.5 | 0.0 to 1.0 | Slides the palette from deep ember to white-hot |
volume | 0.8 | 0.0 to 1.0 | Loudness of the sound |
More from this shader
The same shader 8 more ways: each from a different moment, with different settings, one after another. Each chapter below says which settings moved most, then lists them all.
The full source
This is the whole episode: the picture, the parameters and the sound.
episode-06-molten-metaballs.metal
// Ray Kiln · Episode 6 · Molten Metaballs
//
// @episode 6 Molten Metaballs
// @length 60
// @variation 30
// @variations 1,2,3,4,5,6,8,12
// @still 14.0
// @teaches 3D smooth blending, soft shadows, ambient occlusion
// @category Fragment
// @tags raymarching, signed distance fields, smooth minimum, metaballs, soft shadows, ambient occlusion, procedural sound
// @final Molten | Colour it with the kiln, let the necks where blobs melt together glow, and warm the floor beneath them.
//
// Blobs of molten metal drifting over a kiln floor, melting into each other and into the floor wherever they meet.
// Their shadows on the floor are soft at the edges, and the creases between them darken because little light gets in.
// 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 blobs drift and the camera circles
// @param balls 5.0 2.0 7.0 How many blobs there are
// @param melt 0.5 0.0 1.0 How far apart blobs start to melt into each other and into the floor
// @param rise 1.0 0.0 1.5 How high the blobs float up and how deep they sink into the floor
// @param soft 0.5 0.0 1.0 How soft the shadows are at their edges: 0 is a hard edge
// @param occlusion 1.0 0.0 2.0 How dark the creases get where little light reaches
// @param light 0.15 0.0 1.0 Where the light stands, once around the scene
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
// @param volume 0.8 0.0 1.0 Loudness of the sound
// ---- Shapes ----------------------------------------------------------------------------------------------------
float sdSphere(float3 p, float r) {
return length(p) - r;
}
// Episode 2's smooth minimum: within k of each other, two distances are pulled down together.
float smin(float a, float b, float k) {
float h = max(k - abs(a - b), 0.0) / k;
return min(a, b) - h * h * k * 0.25;
}
// Where blob i is at time t. Each drifts around the middle on its own slow orbit, and bobs up and down.
// The sound uses these too.
float3 ballPos(int i, float t, float rise) {
float fi = float(i);
float a = t * (0.30 + 0.07 * fi) + fi * 2.4;
float r = 0.9 + 0.45 * sin(t * (0.23 + 0.05 * fi) + fi * 2.1);
float y = 0.15 + rise * 0.5 * sin(t * (0.55 + 0.11 * fi) + fi * 1.7);
return float3(r * cos(a), y, r * sin(a));
}
float ballRadius(int i) {
return 0.34 + 0.08 * sin(float(i) * 3.7);
}
constant float FLOOR = -0.55;
// The blobs melt into each other: fold the smooth minimum over them, one at a time.
float blobs(float3 p, float t, int n, float rise, float k) {
float d = 1e9;
for (int i = 0; i < n; i++) {
d = smin(d, sdSphere(p - ballPos(i, t, rise), ballRadius(i)), k);
}
return d;
}
// The same blobs joined with a plain min, to measure how much the smooth minimum pulled them together.
float blobsHard(float3 p, float t, int n, float rise) {
float d = 1e9;
for (int i = 0; i < n; i++) {
d = min(d, sdSphere(p - ballPos(i, t, rise), ballRadius(i)));
}
return d;
}
// Then the blobs melt into the floor too, a little more tightly, so they pool where they touch it.
float map(float3 p, float t, int n, float rise, float k) {
return smin(blobs(p, t, n, rise, k), p.y - FLOOR, k * 0.6);
}
// The normal, as in episode 3: how the distance changes for a small step along each axis.
float3 normalAt(float3 p, float t, int n, float rise, float k) {
const float2 e = float2(0.001, 0.0);
return normalize(float3(
map(p + e.xyy, t, n, rise, k) - map(p - e.xyy, t, n, rise, k),
map(p + e.yxy, t, n, rise, k) - map(p - e.yxy, t, n, rise, k),
map(p + e.yyx, t, n, rise, k) - map(p - e.yyx, t, n, rise, k)));
}
// ---- Light -----------------------------------------------------------------------------------------------------
// A soft shadow: march from the surface towards the light. The nearer the ray passes to something, compared with how
// far it has gone, the deeper the shadow, so a shadow fades out over a band instead of stopping at a line.
float softShadow(float3 ro, float3 rd, float w,
float t, int n, float rise, float k) {
float res = 1.0;
float s = 0.02;
for (int i = 0; i < 48; i++) {
float h = map(ro + rd * s, t, n, rise, k);
res = min(res, h / (w * s));
if (res < 0.001 || s > 6.0) break;
s += clamp(h, 0.01, 0.3);
}
res = clamp(res, 0.0, 1.0);
return res * res * (3.0 - 2.0 * res);
}
// Ambient occlusion: step out along the normal. Where the scene is nearer than the step, something blocks the light
// from that side, and the nearer steps count most.
float ambientOcclusion(float3 pos, float3 nrm,
float t, int n, float rise, float k) {
float occ = 0.0;
float weight = 1.0;
for (int i = 0; i < 5; i++) {
float h = 0.02 + 0.12 * float(i);
occ += (h - map(pos + nrm * h, t, n, rise, k)) * weight;
weight *= 0.8;
}
return clamp(1.0 - 2.0 * occ, 0.0, 1.0);
}
// The camera circles the scene slowly, above it, looking down at the middle of the floor.
float3 cameraPos(float t) {
float a = t * 0.2;
return float3(3.4 * sin(a), 1.6, 3.4 * cos(a));
}
// The kiln palette from episode 1.
float3 kiln(float t) {
float3 c0 = float3(0.020, 0.012, 0.020);
float3 c1 = float3(0.280, 0.040, 0.030);
float3 c2 = float3(0.880, 0.260, 0.050);
float3 c3 = float3(1.000, 0.680, 0.200);
float3 c4 = float3(1.000, 0.970, 0.840);
t = clamp(t, 0.0, 1.0);
float3 c = mix(c0, c1, smoothstep(0.00, 0.22, t));
c = mix(c, c2, smoothstep(0.22, 0.50, t));
c = mix(c, c3, smoothstep(0.50, 0.76, t));
return mix(c, c4, smoothstep(0.76, 1.00, t));
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float t = u.time * p.speed;
int n = int(clamp(round(p.balls), 1.0, 7.0));
float k = max(p.melt * 0.9, 0.0001);
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.1, 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.0 * fw);
float dist = 0.0;
bool hit = false;
for (int i = 0; i < 128; i++) {
float d = map(ro + rd * dist, t, n, p.rise, k);
if (d < 0.001 * dist) { hit = true; break; }
dist += d;
if (dist > 20.0) break;
}
// Behind everything, the dark of the kiln.
if (!hit) return float4(kiln(0.02 + (p.heat - 0.5) * 0.5), 1.0);
float3 pos = ro + rd * dist;
float3 nrm = normalAt(pos, t, n, p.rise, k);
float a = p.light * 6.2831853;
float3 l = normalize(float3(cos(a), 1.4, sin(a)));
float diffuse = max(dot(nrm, l), 0.0);
float w = 0.005 + 0.3 * p.soft;
float shadow = softShadow(pos + nrm * 0.01, l, w, t, n, p.rise, k);
float ao = ambientOcclusion(pos, nrm, t, n, p.rise, k);
ao = mix(1.0, ao, clamp(p.occlusion, 0.0, 1.0));
ao = pow(ao, max(p.occlusion, 1.0));
// A highlight, as in episode 3.
float3 h = normalize(l - rd);
float spec = pow(max(dot(nrm, h), 0.0), 48.0) * diffuse * shadow;
// On a blob or on the floor? Whichever surface is nearer.
float db = blobs(pos, t, n, p.rise, k);
float metal = smoothstep(0.05, -0.05, db - (pos.y - FLOOR));
// How far smin pulled the surface out: a quarter of k mid-neck.
float pull = (blobsHard(pos, t, n, p.rise) - db) / (k * 0.25);
float neck = smoothstep(0.45, 1.0, pull);
// The floor is dark clay, warmed by the metal above it.
float bounce = exp(-max(db, 0.0) * 3.0) * 0.3;
float floorLevel = (0.04 + 0.30 * diffuse * shadow + bounce) * ao;
// The metal is lit, has a highlight, and glows where it melts.
float metalLevel = (0.24 + 0.40 * diffuse * shadow) * ao;
metalLevel += 0.5 * spec + 0.4 * neck;
float level = mix(floorLevel, metalLevel, metal);
// Far away the floor fades into the dark.
level = mix(level, 0.02, smoothstep(3.5, 9.0, dist));
float3 col = kiln(level + (p.heat - 0.5) * 0.5);
return float4(col, 1.0);
}
// ---- Sound -----------------------------------------------------------------------------------------------------
// The sound is built from the blobs' positions, the same functions the picture uses. Under everything, a low E hums
// and grows brighter the more the blobs melt together. Each blob has a note of E minor pentatonic, heard where it is
// on the screen and only when it comes close enough to another blob to start melting into it, so chords swell as blobs
// meet. Above them the metal bubbles: short pops that rise in pitch, more of them the softer the melt.
// Integer hash, so the bubbles stay exact for minutes of sound.
float hashU(uint n) {
n = (n << 13u) ^ n;
n = n * (n * n * 15731u + 789221u) + 1376312589u;
return float(n & 0x7fffffffu) / float(0x7fffffff);
}
// A soft tone with some of its overtones: `bright` from 0 (a pure sine) to 1 (a fuller sound).
float voice(float ph, float bright) {
const float tau = 6.2831853;
return sin(tau * ph) + bright * (0.45 * sin(2.0 * tau * ph) + 0.25 * sin(3.0 * tau * ph) + 0.12 * sin(4.0 * tau * ph));
}
float2 sound(float t, constant Params& p) {
const float tau = 6.2831853;
const float notes[7] = { 164.81, 196.00, 220.00, 246.94, 293.66, 329.63, 392.00 };
float ts = t * p.speed;
int n = int(clamp(round(p.balls), 1.0, 7.0));
float k = max(p.melt * 0.9, 0.0001);
float3 ro = cameraPos(ts);
float3 fw = normalize(float3(0.0, -0.1, 0.0) - ro);
float3 rt = normalize(cross(fw, float3(0.0, 1.0, 0.0)));
// Each blob's note, as loud as it is close to melting into its nearest neighbour.
float2 chord = float2(0.0);
float meltAll = 0.0;
for (int i = 0; i < n; i++) {
float3 pi = ballPos(i, ts, p.rise);
float gap = 10.0;
for (int j = 0; j < n; j++) {
if (j == i) continue;
gap = min(gap, length(pi - ballPos(j, ts, p.rise)) - ballRadius(i) - ballRadius(j));
}
float close = exp(-max(gap, 0.0) * 3.0 / (0.15 + k));
meltAll += close;
float3 rel = pi - ro;
float x = clamp(dot(rel, rt) / max(dot(rel, fw), 0.5) * 0.9, -1.0, 1.0);
float pan = 0.5 + 0.4 * x;
float tone = voice(notes[i] * t, 0.15 + 0.5 * close) * close * 0.05;
chord += float2(1.0 - pan, pan) * tone * 1.4;
}
meltAll /= float(n);
// The low E, a few cents apart in each ear, brighter as the blobs melt.
float b = 0.1 + 0.8 * meltAll;
float2 low = float2(voice(41.20 * 1.002 * t, b) + 0.6 * voice(82.41 * 0.999 * t, b),
voice(41.20 * 0.998 * t, b) + 0.6 * voice(82.41 * 1.001 * t + 0.25, b)) * 0.12;
// Bubbles: time cut into slots; some slots get a pop at a random moment, rising from a random pitch.
float2 bubbles = float2(0.0);
const float slot = 0.17;
float chance = 0.25 + 0.35 * p.melt;
for (int back = 0; back < 2; back++) {
float idx = floor(t / slot) - float(back);
if (idx < 0.0) continue;
uint s = uint(idx);
if (hashU(s * 3u) > chance) continue;
float tau0 = t - (idx + 0.6 * hashU(s * 3u + 1u)) * slot;
if (tau0 < 0.0) continue;
float f0 = 180.0 + 420.0 * hashU(s * 3u + 2u);
float env = (1.0 - exp(-tau0 * 600.0)) * exp(-tau0 * 28.0);
float pop = sin(tau * f0 * (tau0 + 5.0 * tau0 * tau0)) * env;
float pan = 0.2 + 0.6 * hashU(s * 7u + 5u);
bubbles += float2(1.0 - pan, pan) * pop * 0.22;
}
float fadeIn = smoothstep(0.0, 2.0, t);
return tanh((low + chord + bubbles) * 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, 5.0, 0.5, 1.0, 0.5, 1.0, 0.15, 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.
