Morphing Shapes
A circle, a box and a triangle drawn only from their distance functions, morphing into each other while three small circles orbit and melt into them, with a chord whose wave changes shape with the picture.
Watch
The idea
In episode 1 every pixel asked what is the noise here? This time it asks a sharper question: how far am I from the edge of the shape? The answer is a signed distance: negative inside, positive outside, zero exactly on the edge. That one number is enough to draw a shape, glow around it, ring it with contour lines, turn it into another shape, and melt it into its neighbours.
It is built in five steps, and each step is a shader you can run on its own.
Stage 1 · A circle is a distance
The distance from a point to the edge of a circle is the distance to its centre minus its radius: length(p) - r. That is the whole of sdCircle.
Before measuring, the coordinates are centred and made square, so the shorter side of the screen runs from -1.2 to 1.2 and a circle stays round in any window, landscape or portrait. px is the width of one pixel in those units: smoothstep(-px, px, d) blends across exactly one pixel at the edge, so the circle is smooth without being blurry.
stage-1-circle.metal
// Stage 1 · A circle is a distance
// Every pixel measures how far it is from the edge of a circle. Negative means inside, positive means outside.
// @param size 0.55 0.2 0.9 Size of the shape
// The distance to a circle: the distance to its centre, minus its radius.
float sdCircle(float2 p, float r) {
return length(p) - r;
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
// One pixel's width, in units where the shorter side runs from -1.2 to 1.2.
float px = 2.4 / min(u.resolution.x, u.resolution.y);
// Centred and square, whatever the window's shape.
float2 q = (uv - 0.5) * u.resolution * px;
float d = sdCircle(q, p.size);
// Negative is inside: white there, black outside, blended over one pixel.
float inside = 1.0 - smoothstep(-px, px, d);
return float4(float3(inside), 1.0);
}Stage 2 · Seeing the distance
The distance is a number at every pixel, not just a yes or no, so it can be coloured. Cream inside, ember outside, darker as the distance nears zero, and a ripple every 1/rings units. The rings are evenly spaced because the distance grows evenly: one unit of distance is one unit on the screen. The bright line is where d is exactly zero, the edge itself.
stage-2-field.metal
// Stage 2 · Seeing the distance
// The distance is not just inside or outside: it is a number at every pixel. Colour it, and the shape's field appears.
// @param size 0.55 0.2 0.9 Size of the shape
// @param rings 6.0 0.0 16.0 Contour lines per unit of distance
float sdCircle(float2 p, float r) {
return length(p) - r;
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float px = 2.4 / min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution * px;
float d = sdCircle(q, p.size);
// Cream inside the shape, ember outside it.
float3 col = d < 0.0 ? float3(1.0, 0.95, 0.87) : float3(0.88, 0.37, 0.12);
// Darker near the edge, where the distance is close to zero.
col *= 1.0 - exp(-6.0 * abs(d));
// A ripple every 1/rings units, so you can watch the distance grow.
col *= 0.75 + 0.25 * cos(6.2831853 * p.rings * d);
// And the edge itself, where the distance is exactly zero, as a bright line.
col = mix(col, float3(1.0), 1.0 - smoothstep(0.0, 2.0 * px, abs(d)));
return float4(col, 1.0);
}Stage 3 · Morphing
A box and a triangle have distance functions too. sdBox measures how far outside the box a point is along each axis, or, inside it, how close it is to the nearest side. sdTriangle uses the triangle's symmetry: it folds the plane until only one edge matters, then measures to that edge. Subtracting a little from either distance rounds its corners.
To morph, mix two distances. Halfway between a circle's distance and a box's, the zero line is a shape halfway between them. morphShape holds each shape for a moment, then blends it into the next: circle, box, triangle and round again, on a clock set by speed. The mixed distance is not an exact distance any more, but it is close enough to draw with.
stage-3-morph.metal
// Stage 3 · Morphing
// A box and a triangle have distance functions too. Blend two distances and the shape turns from one into the other.
// @param speed 0.5 0.0 2.0 How fast the shapes morph
// @param size 0.55 0.2 0.9 Size of the shape
// @param rings 6.0 0.0 16.0 Contour lines per unit of distance
float sdCircle(float2 p, float r) {
return length(p) - r;
}
// A box: how far outside it on each axis, or inside it, how far to the nearest side.
float sdBox(float2 p, float2 b) {
float2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
// A triangle: fold the plane by its symmetry until only one edge matters, then measure to that edge.
float sdTriangle(float2 p, float r) {
const float k = 1.7320508;
p.x = abs(p.x) - r;
p.y = p.y + r / k;
if (p.x + k * p.y > 0.0) p = float2(p.x - k * p.y, -k * p.x - p.y) / 2.0;
p.x -= clamp(p.x, -2.0 * r, 0.0);
return -length(p) * sign(p.y);
}
// Circle, box, triangle and round again: each shape is held for a moment, then blended into the next.
float morphShape(float2 p, float r, float t) {
float s = fract(t / 3.0) * 3.0;
float f = smoothstep(0.5, 1.0, fract(s));
float a = sdCircle(p, r);
// Subtracting a little from a distance rounds the corners.
float b = sdBox(p, float2(r * 0.8)) - r * 0.1;
float c = sdTriangle(p, r * 1.2) - r * 0.1;
// Mixing two distances gives a shape part way between them.
if (s < 1.0) return mix(a, b, f);
if (s < 2.0) return mix(b, c, f);
return mix(c, a, f);
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float px = 2.4 / min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution * px;
float t = u.time * p.speed;
float d = morphShape(q, p.size, t);
float3 col = d < 0.0 ? float3(1.0, 0.95, 0.87) : float3(0.88, 0.37, 0.12);
col *= 1.0 - exp(-6.0 * abs(d));
col *= 0.75 + 0.25 * cos(6.2831853 * p.rings * d);
col = mix(col, float3(1.0), 1.0 - smoothstep(0.0, 2.0 * px, abs(d)));
return float4(col, 1.0);
}Stage 4 · Melting shapes together
The min of two distances draws both shapes at once, since a point is as close to the pair as it is to the nearer one. Three small circles orbit the middle shape, each on its own path, and the scene is the minimum of all four.
A plain min leaves a sharp crease where shapes touch. smin, a smooth minimum, pulls the distance down wherever the two are within k of each other, so the shapes bulge towards each other and melt together like drops of wax. blend sets k. At 0 it is a plain min, with a sharp crease where shapes overlap; at 1 they reach for each other from far apart. It is never exactly zero, because smin divides by it.
stage-4-blend.metal
// Stage 4 · Melting shapes together
// The smallest distance draws every shape at once. A smooth minimum melts them into each other where they meet.
// @param speed 0.5 0.0 2.0 How fast the shapes morph and orbit
// @param size 0.55 0.2 0.9 Size of the shape
// @param blend 0.35 0.0 1.0 How softly the shapes melt into each other
// @param rings 6.0 0.0 16.0 Contour lines per unit of distance
float sdCircle(float2 p, float r) {
return length(p) - r;
}
float sdBox(float2 p, float2 b) {
float2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
float sdTriangle(float2 p, float r) {
const float k = 1.7320508;
p.x = abs(p.x) - r;
p.y = p.y + r / k;
if (p.x + k * p.y > 0.0) p = float2(p.x - k * p.y, -k * p.x - p.y) / 2.0;
p.x -= clamp(p.x, -2.0 * r, 0.0);
return -length(p) * sign(p.y);
}
float morphShape(float2 p, float r, float t) {
float s = fract(t / 3.0) * 3.0;
float f = smoothstep(0.5, 1.0, fract(s));
float a = sdCircle(p, r);
float b = sdBox(p, float2(r * 0.8)) - r * 0.1;
float c = sdTriangle(p, r * 1.2) - r * 0.1;
if (s < 1.0) return mix(a, b, f);
if (s < 2.0) return mix(b, c, f);
return mix(c, a, f);
}
// A smooth minimum: like min, but it bulges where two distances are within k.
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 small circle i is at time t, on its own orbit.
float2 orbit(int i, float t) {
float a = t * (0.7 + 0.23 * float(i)) + 2.094 * float(i);
float2 o = float2(cos(a), sin(a) * 0.8);
return o * (0.8 + 0.12 * sin(t * 0.5 + float(i)));
}
// The scene: the morphing shape with three circles melted in.
float scene(float2 p, float r, float t, float k) {
float d = morphShape(p, r, t);
for (int i = 0; i < 3; i++) {
d = smin(d, sdCircle(p - orbit(i, t), r * 0.32), k);
}
return d;
}
float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
float px = 2.4 / min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution * px;
float t = u.time * p.speed;
// How far apart shapes still melt. Never 0: smin divides by it.
float k = max(p.blend * 0.6, 0.0001);
float d = scene(q, p.size, t, k);
float3 col = d < 0.0 ? float3(1.0, 0.95, 0.87) : float3(0.88, 0.37, 0.12);
col *= 1.0 - exp(-6.0 * abs(d));
col *= 0.75 + 0.25 * cos(6.2831853 * p.rings * d);
col = mix(col, float3(1.0), 1.0 - smoothstep(0.0, 2.0 * px, abs(d)));
return float4(col, 1.0);
}Stage 5 · Heat and glow
The last step colours the distance with the kiln palette from episode 1. Inside, the heat rises towards the middle of the shape, so the core is white-hot. Outside, a glow fades with distance, and rings ripple outward from the edge, since subtracting the time from their phase moves them away from the shape. The same one-pixel blend joins the two, and heat slides the whole picture along the palette.
the kiln palette and shade(), from the finished shader
// The kiln palette from week 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 px = 2.4 / min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution * px;
float t = u.time * p.speed;
float k = max(p.blend * 0.6, 0.0001);
float d = scene(q, p.size, t, k);
// Inside: hotter towards the middle.
float core = 0.62 + 0.38 * clamp(-d * 2.5, 0.0, 1.0);
// Outside: a glow, and rings rippling outward.
float glow = exp(-max(d, 0.0) * 4.0);
float ring = 0.5 + 0.5 * cos(6.2831853 * (p.rings * d - t));
float outside = 0.06 + 0.36 * glow + 0.16 * ring * exp(-max(d, 0.0) * 1.5);
// Join the two over one pixel, then slide along the palette by heat.
float heat = mix(core, outside, smoothstep(-px, px, d));
float3 col = kiln(heat + (p.heat - 0.5) * 0.5);
return float4(col, 1.0);
}The sound
The sound is in the same file and runs on the same clock as the picture. A low chord on A (A2, E3 and A3, a few cents apart in each ear) changes the shape of its wave with the shape on screen: a sine wave for the circle, a square wave for the box, a triangle wave for the triangle. The square and triangle are built from their first four odd harmonics, so they stay soft. During a morph the wave is part one and part the other, just as the distance is.
Each orbiting circle has a high note of its own (A4, C♯5 and E5), heard from where it is on the screen. The note swells as the circle nears the middle shape and melts into it: the sound measures the same distance the picture does. A soft bell rings as each morph begins.
Try this
- Set
blendto 0, then slowly raise it, and watch the circles reach for the middle shape. - Set
ringsto 16 to see the distance field in fine detail, or 0 for a plain glow. - Set
speedto 0 to stop the clock and look at a single shape. - Swap
sdTrianglefor another shape, such as a box with a different size on each axis, and watch the morph change.
Parameters
| Parameter | Default | Range | What it does |
|---|---|---|---|
speed | 0.5 | 0.0 to 2.0 | How fast the shapes morph and orbit |
size | 0.55 | 0.2 to 0.9 | Size of the shape |
blend | 0.35 | 0.0 to 1.0 | How softly the shapes melt into each other |
rings | 6.0 | 0.0 to 16.0 | Contour lines per unit of distance |
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 with different settings, each from a different moment, played back to back. The settings of each one are in the video's chapters.
The full source
This is the whole episode: the picture, the parameters and the sound.
week-02-morphing-shapes.metal
// Ray Kiln · Week 2 · Morphing Shapes
//
// @episode 2 Morphing Shapes
// @length 60
// @short 30
// @still 19.2
// @teaches 2D signed distance fields and smooth blending
// @category Fragment
// @tags signed distance fields, smooth minimum, morphing, procedural sound
// @final Heat and glow | Colour by distance: a hot core inside, a glow outside, and rings that ripple away from the edge.
//
// A circle, a box and a triangle described only by their distance functions, morphing into each other while three
// small circles orbit and melt into them, run through the kiln palette.
// Each @param line below becomes a slider in the host app and a row in the page's parameters table:
// // @param name default min max description
// @param speed 0.5 0.0 2.0 How fast the shapes morph and orbit
// @param size 0.55 0.2 0.9 Size of the shape
// @param blend 0.35 0.0 1.0 How softly the shapes melt into each other
// @param rings 6.0 0.0 16.0 Contour lines per unit of distance
// @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
float sdCircle(float2 p, float r) {
return length(p) - r;
}
float sdBox(float2 p, float2 b) {
float2 d = abs(p) - b;
return length(max(d, 0.0)) + min(max(d.x, d.y), 0.0);
}
float sdTriangle(float2 p, float r) {
const float k = 1.7320508;
p.x = abs(p.x) - r;
p.y = p.y + r / k;
if (p.x + k * p.y > 0.0) p = float2(p.x - k * p.y, -k * p.x - p.y) / 2.0;
p.x -= clamp(p.x, -2.0 * r, 0.0);
return -length(p) * sign(p.y);
}
float morphShape(float2 p, float r, float t) {
float s = fract(t / 3.0) * 3.0;
float f = smoothstep(0.5, 1.0, fract(s));
float a = sdCircle(p, r);
float b = sdBox(p, float2(r * 0.8)) - r * 0.1;
float c = sdTriangle(p, r * 1.2) - r * 0.1;
if (s < 1.0) return mix(a, b, f);
if (s < 2.0) return mix(b, c, f);
return mix(c, a, f);
}
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;
}
float2 orbit(int i, float t) {
float a = t * (0.7 + 0.23 * float(i)) + 2.094 * float(i);
float2 o = float2(cos(a), sin(a) * 0.8);
return o * (0.8 + 0.12 * sin(t * 0.5 + float(i)));
}
float scene(float2 p, float r, float t, float k) {
float d = morphShape(p, r, t);
for (int i = 0; i < 3; i++) {
d = smin(d, sdCircle(p - orbit(i, t), r * 0.32), k);
}
return d;
}
// The kiln palette from week 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 px = 2.4 / min(u.resolution.x, u.resolution.y);
float2 q = (uv - 0.5) * u.resolution * px;
float t = u.time * p.speed;
float k = max(p.blend * 0.6, 0.0001);
float d = scene(q, p.size, t, k);
// Inside: hotter towards the middle.
float core = 0.62 + 0.38 * clamp(-d * 2.5, 0.0, 1.0);
// Outside: a glow, and rings rippling outward.
float glow = exp(-max(d, 0.0) * 4.0);
float ring = 0.5 + 0.5 * cos(6.2831853 * (p.rings * d - t));
float outside = 0.06 + 0.36 * glow + 0.16 * ring * exp(-max(d, 0.0) * 1.5);
// Join the two over one pixel, then slide along the palette by heat.
float heat = mix(core, outside, smoothstep(-px, px, d));
float3 col = kiln(heat + (p.heat - 0.5) * 0.5);
return float4(col, 1.0);
}
// ---- Sound -----------------------------------------------------------------------------------------------------
// The sound follows the same clock as the picture. A low chord changes the shape of its wave with the shape on screen:
// a sine for the circle, a square for the box, a triangle for the triangle. Each orbiting circle sings a high note
// that swells as it melts into the middle shape, and a soft bell rings each time a morph begins.
// One wave, part sine, part square, part triangle, at phase `ph` in cycles. The square and triangle are built from
// their first four odd harmonics, so they stay soft.
float morphWave(float ph, float3 w) {
const float tau = 6.2831853;
float sq = 0.0, tri = 0.0;
for (int n = 1; n <= 7; n += 2) {
float s = sin(tau * float(n) * ph);
sq += s / float(n);
tri += s * ((n / 2) % 2 == 0 ? 1.0 : -1.0) / float(n * n);
}
return w.x * sin(tau * ph) + w.y * sq * 0.75 + w.z * tri * 0.81;
}
float2 sound(float t, constant Params& p) {
const float tau = 6.2831853;
float ts = t * p.speed;
// How much of each shape is on screen, from the same clock as morphShape.
float s = fract(ts / 3.0) * 3.0;
float f = smoothstep(0.5, 1.0, fract(s));
float3 w = s < 1.0 ? float3(1.0 - f, f, 0.0) : (s < 2.0 ? float3(0.0, 1.0 - f, f) : float3(f, 0.0, 1.0 - f));
// A chord on A (A2 E3 A3), a few cents apart in each ear, with the wave shape of the shape on screen.
const float freq[3] = {110.0, 164.81, 220.0};
const float amp[3] = {1.0, 0.6, 0.45};
float2 chord = float2(0.0);
for (int i = 0; i < 3; i++) {
chord.x += amp[i] * morphWave(freq[i] * 1.001 * t, w);
chord.y += amp[i] * morphWave(freq[i] * 0.999 * t + 0.25, w);
}
chord *= (0.8 + 0.2 * sin(tau * t / 9.0)) * 0.09;
// The orbiting circles: a note each (A4, C#5, E5), louder the closer the circle is to melting into the middle,
// and heard from where it is on the screen.
const float note[3] = {440.0, 554.37, 659.25};
float2 high = float2(0.0);
for (int i = 0; i < 3; i++) {
float2 o = orbit(i, ts);
float gap = morphShape(o, p.size, ts) - p.size * 0.32;
float near = exp(-max(gap, 0.0) * (4.0 / (0.15 + p.blend)));
float tone = sin(tau * note[i] * t) + 0.25 * sin(tau * note[i] * 2.0 * t);
float pan = clamp(0.5 + 0.4 * o.x, 0.0, 1.0);
high += float2(1.0 - pan, pan) * tone * near * 0.04;
}
// A soft bell as each morph begins, fading out before the next one.
// Before the morph begins `since` is clamped to 0, where the envelope is silent (and exp cannot overflow).
float since = max(fract(s) - 0.5, 0.0);
float bellEnv = exp(-since * 7.0) * (1.0 - exp(-since * 300.0)) * (1.0 - smoothstep(0.35, 0.5, since));
float bell = (sin(tau * 880.0 * t) + 0.4 * sin(tau * 1320.0 * t) + 0.2 * sin(tau * 2217.0 * t)) * bellEnv * 0.05;
float fadeIn = smoothstep(0.0, 2.0, t);
return tanh((chord + high + float2(bell)) * 2.0) * 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, 0.55, 0.35, 6.0, 0.5, 0.8] // the @param defaults, in the order they are declared
encoder.setRenderPipelineState(pipeline)
encoder.setFragmentBytes(&uniforms, length: MemoryLayout<Uniforms>.stride, index: 0)
encoder.setFragmentBytes(¶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.