Rippling Water
Rain on still water under a sky lit like a kiln. The first episode drawn by a vertex shader, it lifts every corner of a 320 by 320 grid to the height of the water there. Each drop starts a band of rings that spreads, weakens and fades, the rings add up where they cross, a slow swell rolls under them, and the surface mirrors the sky as much as its angle allows. Every ring starts with a plink of its own.
Teaches A vertex shader displacing a grid
Builds on 05 · Cracked Clay (integer hash, sound noise)
Watch
The idea
Every episode so far has been drawn one pixel at a time: a fragment shader is asked for the colour at each point of the picture and works out what is there. This one is drawn the way most 3D on a screen is drawn: from triangles.
The host lays down a flat grid, 320 by 320 squares, each cut into two triangles, and runs the episode's vertex shader, corner, once for every corner of every triangle. corner gets the corner's place on the grid, from 0 to 1 across and down, and decides where it goes: into the world, lifted to the height of the water at that point, and from there onto the screen through a camera. The GPU then fills each triangle, blending what its three corners carry, and runs shade for every pixel inside. Nearer triangles hide farther ones with a depth test. Moving the corners is called displacement: the grid stays a grid, but bent into the shape of the water.
It is built in five steps, and each one is a shader you can run on its own.
Stage 1 · A flat sheet
The grid here is small, 24 by 24, so its squares are easy to count. corner turns the grid's 0 to 1 into a sheet 12 units wide and 12 deep and lays it flat, at height 0, in front of an eye half a unit above it.
project is the camera. cameraAxes builds three directions from where the eye is and where it looks (right, up and forward, as the three columns of a float3x3), and project measures the corner along each. The host wants four numbers back: x and y on the screen, a depth from 0 to 1 for the depth test, and the distance ahead, w. The GPU divides x, y and the depth by w itself, so whatever is twice as far away comes out half as big. That division is the whole of perspective.
shade gets each pixel's world position, blended from the triangle's corners, and colours tiles two to a unit in two shades of the kiln palette, fading into the dark far off. Each tile is exactly one square of the grid.
stage-1-flat.metal
// Stage 1 · A flat sheet
// A vertex shader starts from a grid: the host lays down 24 by 24 squares,
// two triangles each, and calls corner for every corner. Here each corner
// only moves into place, a flat sheet seen from low over it.
//
// @grid 24
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
// The eye, low over the water, and the point it looks at.
constant float3 EYE = float3(0.0, 0.5, -1.2);
constant float3 AIM = float3(0.0, 0.2, 4.0);
// What each corner carries to the pixels between: where it lands on screen,
// and where it is in the world.
struct Vertex {
float4 position [[position]];
float3 world;
};
// Episode 1's kiln palette: near-black, ember, orange, gold, white-hot.
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));
}
// The camera's three directions: right, up and forward.
float3x3 cameraAxes() {
float3 f = normalize(AIM - EYE);
float3 r = normalize(cross(float3(0, 1, 0), f));
return float3x3(r, cross(f, r), f);
}
// A point in the world as the host wants it: x and y on screen (times
// the depth), the depth from 0 to 1 for the depth test, and the depth itself.
// The host divides x and y by the depth, so far things come out smaller.
float4 project(float3 world, float aspect) {
float3x3 axes = cameraAxes();
float3 v = world - EYE;
float3 view = float3(dot(v, axes[0]), dot(v, axes[1]), dot(v, axes[2]));
float zoom = 1.7, near = 0.1, far = 30.0;
float depth = (view.z - near) / (far - near) * view.z;
return float4(view.x * zoom / aspect, view.y * zoom, depth, view.z);
}
// Once for every corner: the grid's 0 to 1 across becomes a sheet 12 wide
// and 12 deep, lying flat in front of the eye.
Vertex corner(float2 grid, constant Uniforms& u, constant Params& p) {
float x = (grid.x - 0.5) * 12.0;
float z = grid.y * 12.0 - 0.6;
Vertex o;
o.world = float3(x, 0.0, z);
o.position = project(o.world, u.resolution.x / u.resolution.y);
return o;
}
// Once for every pixel of the sheet: tiles, two to a unit, so the
// perspective shows, fading into the dark far away.
float4 shade(Vertex v, constant Uniforms& u, constant Params& p) {
float2 tile = floor(v.world.xz * 2.0);
float odd = fmod(abs(tile.x + tile.y), 2.0);
float far = smoothstep(2.0, 11.0, v.world.z);
float t = mix(0.35 + 0.2 * odd, 0.15, far) + (p.heat - 0.5) * 0.4;
return float4(kiln(t), 1.0);
}
// Behind the sheet: a plain dark gradient, lighter low down.
float4 background(float2 uv, constant Uniforms& u, constant Params& p) {
return float4(kiln(0.3 - 0.25 * uv.y + (p.heat - 0.5) * 0.4), 1.0);
}Stage 2 · One ring
Now corner lifts each corner. waterHeight is a wave running out from one point ahead, sin(k × r - 3t) at a distance r from it, divided by 1 + 2r so it weakens as it spreads; size is the distance from one crest to the next.
To light the water shade needs to know which way it faces at each point: its normal. corner works it out from the height a little to each side. If the water rises by hx - h over a small step e along x, the normal leans back against that slope, and the same along z, so the normal is (h - hx, e, h - hz), made one unit long. The light is the kiln's glow, low and straight ahead: a slope turned toward it is bright, one turned away is dark, and the rings stand out though the sheet is seen almost edge-on.
The grid is 320 by 320 from here on. A crest needs several corners across it to look round; with too few, each ring breaks into flat facets.
stage-2-one-ring.metal18 new lines since stage 1
// Stage 2 · One ring
// Now corner lifts each corner. The height is a wave running out from one
// point, weaker the farther it goes. Lighting it needs the way the surface
// faces (its normal), which comes from the height a little to each side.
//
// @grid 320
// @param speed 1.0 0.0 3.0 How fast the ring runs outward
// @param height 0.035 0.0 0.08 How tall the ring is
// @param size 0.3 0.15 0.7 The distance from one ring to the next
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
constant float TAU = 6.2831853;
// The eye, low over the water, and the point it looks at.
constant float3 EYE = float3(0.0, 0.5, -1.2);
constant float3 AIM = float3(0.0, 0.2, 4.0);
// The kiln's glow: low in the sky, straight ahead.
constant float3 GLOW = float3(0.0, 0.09, 0.996);
// What each corner carries to the pixels between: where it lands on screen,
// where it is in the world, and which way the water faces there (its normal).
struct Vertex {
float4 position [[position]];
float3 world;
float3 normal;
};
// Episode 1's kiln palette: near-black, ember, orange, gold, white-hot.
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));
}
// The camera's three directions: right, up and forward.
float3x3 cameraAxes() {
float3 f = normalize(AIM - EYE);
float3 r = normalize(cross(float3(0, 1, 0), f));
return float3x3(r, cross(f, r), f);
}
// A point in the world as the host wants it: x and y on screen (times
// the depth), the depth from 0 to 1 for the depth test, and the depth itself.
float4 project(float3 world, float aspect) {
float3x3 axes = cameraAxes();
float3 v = world - EYE;
float3 view = float3(dot(v, axes[0]), dot(v, axes[1]), dot(v, axes[2]));
float zoom = 1.7, near = 0.1, far = 30.0;
float depth = (view.z - near) / (far - near) * view.z;
return float4(view.x * zoom / aspect, view.y * zoom, depth, view.z);
}
// The water's height at a point: waves running out from a point ahead,
// smaller the farther they have gone.
float waterHeight(float2 xz, float t, constant Params& p) {
float r = length(xz - float2(0.0, 2.5));
float k = TAU / p.size;
return p.height * sin(k * r - 3.0 * t) / (1.0 + 2.0 * r);
}
// Once for every corner: the flat sheet, lifted to the water's height. The
// normal compares the height a little to each side: the steeper the slope,
// the more the normal leans back against it.
Vertex corner(float2 grid, constant Uniforms& u, constant Params& p) {
float x = (grid.x - 0.5) * 12.0;
float z = grid.y * 12.0 - 0.6;
float t = u.time * p.speed;
float e = 0.01;
float h = waterHeight(float2(x, z), t, p);
float hx = waterHeight(float2(x + e, z), t, p);
float hz = waterHeight(float2(x, z + e), t, p);
Vertex o;
o.world = float3(x, h, z);
o.position = project(o.world, u.resolution.x / u.resolution.y);
o.normal = normalize(float3(h - hx, e, h - hz));
return o;
}
// Once for every pixel: lit by the glow ahead. A slope that turns toward it
// is bright, one that turns away is dark, and the sheet fades far off.
float4 shade(Vertex v, constant Uniforms& u, constant Params& p) {
float3 n = normalize(v.normal);
float lit = max(dot(n, GLOW), 0.0);
float far = smoothstep(2.0, 11.0, v.world.z);
float t = mix(0.15 + 3.0 * lit, 0.15, far) + (p.heat - 0.5) * 0.4;
return float4(kiln(t), 1.0);
}
// Behind the sheet: a plain dark gradient, lighter low down.
float4 background(float2 uv, constant Uniforms& u, constant Params& p) {
return float4(kiln(0.3 - 0.25 * uv.y + (p.heat - 0.5) * 0.4), 1.0);
}4 lines from stage 1 that are gone
o.world = float3(x, 0.0, z);
⋯
float2 tile = floor(v.world.xz * 2.0);
float odd = fmod(abs(tile.x + tile.y), 2.0);
⋯
float t = mix(0.35 + 0.2 * odd, 0.15, far) + (p.heat - 0.5) * 0.4;Stage 3 · Raindrops
Many drops instead of one point. drop says where and when each one falls: there are seven slots, and each drops once every 7 / rain seconds at its own moment, with a little jitter, at a place chosen by episode 5's hash. More fall near the eye, and the band of places widens with the distance, as the view does.
rings is one drop's water: a band of rings whose front runs outward at 0.55 units a second. The band widens as it goes (width grows with age), its height falls away with time (exp(-0.75 × age)) and with distance, and it rises over the first tenth of a second so a drop never pops in. water adds the rings of every slot's last few drops at the point, so where two rings cross their heights add, and they pass through each other.
The slope comes with the height now. rings returns not only the height but how fast it changes with r, worked out by hand from the same formula, and water turns that into slopes along x and z (to / r points away from the drop). So corner gets the normal from the slopes directly, (-slope x, 1, -slope z), without asking for the height twice more.
stage-3-raindrops.metal49 new lines since stage 2
// Stage 3 · Raindrops
// Many drops instead of one point: each lands somewhere at its own moment and
// starts a band of rings that spreads and fades. Where rings cross, their
// heights add, and so do their slopes.
//
// @grid 320
// @param speed 1.0 0.0 3.0 How fast the rings spread
// @param rain 1.6 0.3 5.0 Drops a second
// @param height 0.035 0.0 0.08 How tall the rings are
// @param size 0.3 0.15 0.7 The distance from one ring to the next
// @param heat 0.5 0.0 1.0 Slides the palette from deep ember to white-hot
constant float TAU = 6.2831853;
// How many drops can be on their way at once.
constant int SLOTS = 7;
// The eye, low over the water, and the point it looks at.
constant float3 EYE = float3(0.0, 0.5, -1.2);
constant float3 AIM = float3(0.0, 0.2, 4.0);
// The kiln's glow: low in the sky, straight ahead.
constant float3 GLOW = float3(0.0, 0.09, 0.996);
// What each corner carries to the pixels between: where it lands on screen,
// where it is in the world, and which way the water faces there (its normal).
struct Vertex {
float4 position [[position]];
float3 world;
float3 normal;
};
// Episode 5's hash: a whole number in, a random 0 to 1 out.
float hashU(uint n) {
n = (n << 13u) ^ n;
n = n * (n * n * 15731u + 789221u) + 1376312589u;
return float(n & 0x7fffffffu) / float(0x7fffffff);
}
// Episode 1's kiln palette: near-black, ember, orange, gold, white-hot.
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));
}
// The camera's three directions: right, up and forward.
float3x3 cameraAxes() {
float3 f = normalize(AIM - EYE);
float3 r = normalize(cross(float3(0, 1, 0), f));
return float3x3(r, cross(f, r), f);
}
// A point in the world as the host wants it: x and y on screen (times
// the depth), the depth from 0 to 1 for the depth test, and the depth itself.
float4 project(float3 world, float aspect) {
float3x3 axes = cameraAxes();
float3 v = world - EYE;
float3 view = float3(dot(v, axes[0]), dot(v, axes[1]), dot(v, axes[2]));
float zoom = 1.7, near = 0.1, far = 30.0;
float depth = (view.z - near) / (far - near) * view.z;
return float4(view.x * zoom / aspect, view.y * zoom, depth, view.z);
}
// One drop: where it lands (x, z), when, and a random number of its own.
// Each slot drops once every SLOTS / rain seconds, at a moment of its own.
float4 drop(int slot, int cycle, constant Params& p) {
float period = float(SLOTS) / p.rain;
uint k = uint(slot) * 7919u + uint(cycle) * 104729u;
float jitter = (hashU(k + 3u) - 0.5) * 0.6;
float moment = (float(slot) + 0.5 + jitter) / float(SLOTS);
float z = 0.2 + pow(hashU(k + 2u), 1.4) * 5.5;
float x = (hashU(k + 1u) - 0.5) * (2.0 + 1.4 * z);
return float4(x, z, (float(cycle) + moment) * period, hashU(k + 4u));
}
// One drop's rings, a distance r from where it fell, `age` seconds after:
// a band of rings that runs outward, spreads and weakens. Returns the height
// and how fast it changes with r, worked out by hand from the same formula.
float2 rings(float r, float age, constant Params& p) {
float k = TAU / p.size;
float front = r - 0.55 * age;
float width = 0.18 + 0.22 * age;
float a = p.height * exp(-0.75 * age) / (1.0 + 2.0 * r);
a *= smoothstep(0.0, 0.12, age);
float band = exp(-front * front / (width * width));
float s = sin(k * front), c = cos(k * front);
float da = -2.0 * a / (1.0 + 2.0 * r);
float dband = -2.0 * front / (width * width) * band;
float dh = da * band * s + a * dband * s + a * band * k * c;
return float2(a * band * s, dh);
}
// The water at a point: its height (x) and its slope along x (y) and z (z).
// Every slot's last few drops add their rings, so rings cross and add up.
float3 water(float2 xz, float t, constant Params& p) {
float3 w = float3(0.0);
float period = float(SLOTS) / p.rain;
for (int i = 0; i < SLOTS; i++) {
float slotTime = t / period - (float(i) + 0.5) / float(SLOTS);
int now = int(floor(slotTime));
for (int back = -1; back < 3; back++) {
int cycle = now - back;
if (cycle < 0) continue;
float4 d = drop(i, cycle, p);
float age = t - d.z;
if (age < 0.0 || age > 9.0) continue;
float2 to = xz - d.xy;
float r = max(length(to), 1e-4);
float2 hr = rings(r, age, p);
w += float3(hr.x, hr.y * to / r);
}
}
return w;
}
// Once for every corner: the flat sheet, lifted to the water's height.
// The slope comes with the height, so the normal needs no extra samples.
Vertex corner(float2 grid, constant Uniforms& u, constant Params& p) {
float x = (grid.x - 0.5) * 12.0;
float z = grid.y * 12.0 - 0.6;
float3 w = water(float2(x, z), u.time * p.speed, p);
Vertex o;
o.world = float3(x, w.x, z);
o.position = project(o.world, u.resolution.x / u.resolution.y);
o.normal = normalize(float3(-w.y, 1.0, -w.z));
return o;
}
// Once for every pixel: lit by the glow ahead. A slope that turns toward it
// is bright, one that turns away is dark, and the sheet fades far off.
float4 shade(Vertex v, constant Uniforms& u, constant Params& p) {
float3 n = normalize(v.normal);
float lit = max(dot(n, GLOW), 0.0);
float far = smoothstep(2.0, 11.0, v.world.z);
float t = mix(0.15 + 3.0 * lit, 0.15, far) + (p.heat - 0.5) * 0.4;
return float4(kiln(t), 1.0);
}
// Behind the sheet: a plain dark gradient, lighter low down.
float4 background(float2 uv, constant Uniforms& u, constant Params& p) {
return float4(kiln(0.3 - 0.25 * uv.y + (p.heat - 0.5) * 0.4), 1.0);
}10 lines from stage 2 that are gone
float waterHeight(float2 xz, float t, constant Params& p) {
float r = length(xz - float2(0.0, 2.5));
⋯
return p.height * sin(k * r - 3.0 * t) / (1.0 + 2.0 * r);
⋯
float t = u.time * p.speed;
float e = 0.01;
float h = waterHeight(float2(x, z), t, p);
float hx = waterHeight(float2(x + e, z), t, p);
float hz = waterHeight(float2(x, z + e), t, p);
⋯
o.world = float3(x, h, z);
⋯
o.normal = normalize(float3(h - hx, e, h - hz));Stage 4 · A mirror of the sky
Water isn't lit much by light falling on it. What you see on it is mostly what it reflects. shade sends the eye's ray to the pixel, bounces it off the surface with reflect and looks up kilnSky in that direction: gold at the horizon, ember higher up, near-black overhead, and the kiln's glow, straight ahead, with a wide haze around it.
How much it reflects depends on the angle. mirror is Schlick's approximation of the Fresnel effect: water seen from straight above reflects 2% and shows the dark under it (deepWater); seen almost edge-on it is nearly a perfect mirror. So the water near the eye is dark, the far water shines, and each ring catches the sky on the side that tilts it back toward the horizon. background fills the rest of the picture with the same kilnSky, sending a ray through each pixel with the camera's own axes.
The sheet's far edge and its sides now show against the sky.
stage-4-mirror.metal25 new lines since stage 3
// Stage 4 · A mirror of the sky
// Water is lit by what it reflects. Each pixel bounces the eye's ray off the
// surface and looks the sky up in that direction, a sky lit like a kiln.
//
// @grid 320
// @param speed 1.0 0.0 3.0 How fast the rings spread
// @param rain 1.6 0.3 5.0 Drops a second
// @param height 0.035 0.0 0.08 How tall the rings are
// @param size 0.3 0.15 0.7 The distance from one ring to the next
// @param heat 0.5 0.0 1.0 Slides the sky from deep ember to white-hot
constant float TAU = 6.2831853;
// How many drops can be on their way at once.
constant int SLOTS = 7;
// The eye, low over the water, and the point it looks at.
constant float3 EYE = float3(0.0, 0.5, -1.2);
constant float3 AIM = float3(0.0, 0.2, 4.0);
// The kiln's glow: low in the sky, straight ahead.
constant float3 GLOW = float3(0.0, 0.09, 0.996);
// What each corner carries to the pixels between: where it lands on screen,
// where it is in the world, and which way the water faces there (its normal).
struct Vertex {
float4 position [[position]];
float3 world;
float3 normal;
};
// Episode 5's hash: a whole number in, a random 0 to 1 out.
float hashU(uint n) {
n = (n << 13u) ^ n;
n = n * (n * n * 15731u + 789221u) + 1376312589u;
return float(n & 0x7fffffffu) / float(0x7fffffff);
}
// Episode 1's kiln palette: near-black, ember, orange, gold, white-hot.
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));
}
// The camera's three directions: right, up and forward.
float3x3 cameraAxes() {
float3 f = normalize(AIM - EYE);
float3 r = normalize(cross(float3(0, 1, 0), f));
return float3x3(r, cross(f, r), f);
}
// A point in the world as the host wants it: x and y on screen (times
// the depth), the depth from 0 to 1 for the depth test, and the depth itself.
float4 project(float3 world, float aspect) {
float3x3 axes = cameraAxes();
float3 v = world - EYE;
float3 view = float3(dot(v, axes[0]), dot(v, axes[1]), dot(v, axes[2]));
float zoom = 1.7, near = 0.1, far = 30.0;
float depth = (view.z - near) / (far - near) * view.z;
return float4(view.x * zoom / aspect, view.y * zoom, depth, view.z);
}
// One drop: where it lands (x, z), when, and a random number of its own.
// Each slot drops once every SLOTS / rain seconds, at a moment of its own.
float4 drop(int slot, int cycle, constant Params& p) {
float period = float(SLOTS) / p.rain;
uint k = uint(slot) * 7919u + uint(cycle) * 104729u;
float jitter = (hashU(k + 3u) - 0.5) * 0.6;
float moment = (float(slot) + 0.5 + jitter) / float(SLOTS);
float z = 0.2 + pow(hashU(k + 2u), 1.4) * 5.5;
float x = (hashU(k + 1u) - 0.5) * (2.0 + 1.4 * z);
return float4(x, z, (float(cycle) + moment) * period, hashU(k + 4u));
}
// One drop's rings, a distance r from where it fell, `age` seconds after:
// a band of rings that runs outward, spreads and weakens. Returns the height
// and how fast it changes with r, worked out by hand from the same formula.
float2 rings(float r, float age, constant Params& p) {
float k = TAU / p.size;
float front = r - 0.55 * age;
float width = 0.18 + 0.22 * age;
float a = p.height * exp(-0.75 * age) / (1.0 + 2.0 * r);
a *= smoothstep(0.0, 0.12, age);
float band = exp(-front * front / (width * width));
float s = sin(k * front), c = cos(k * front);
float da = -2.0 * a / (1.0 + 2.0 * r);
float dband = -2.0 * front / (width * width) * band;
float dh = da * band * s + a * dband * s + a * band * k * c;
return float2(a * band * s, dh);
}
// The water at a point: its height (x) and its slope along x (y) and z (z).
// Every slot's last few drops add their rings, so rings cross and add up.
float3 water(float2 xz, float t, constant Params& p) {
float3 w = float3(0.0);
float period = float(SLOTS) / p.rain;
for (int i = 0; i < SLOTS; i++) {
float slotTime = t / period - (float(i) + 0.5) / float(SLOTS);
int now = int(floor(slotTime));
for (int back = -1; back < 3; back++) {
int cycle = now - back;
if (cycle < 0) continue;
float4 d = drop(i, cycle, p);
float age = t - d.z;
if (age < 0.0 || age > 9.0) continue;
float2 to = xz - d.xy;
float r = max(length(to), 1e-4);
float2 hr = rings(r, age, p);
w += float3(hr.x, hr.y * to / r);
}
}
return w;
}
// Once for every corner: the flat sheet, lifted to the water's height.
// The slope comes with the height, so the normal needs no extra samples.
Vertex corner(float2 grid, constant Uniforms& u, constant Params& p) {
float x = (grid.x - 0.5) * 12.0;
float z = grid.y * 12.0 - 0.6;
float3 w = water(float2(x, z), u.time * p.speed, p);
Vertex o;
o.world = float3(x, w.x, z);
o.position = project(o.world, u.resolution.x / u.resolution.y);
o.normal = normalize(float3(-w.y, 1.0, -w.z));
return o;
}
// The sky in a direction: gold at the horizon, near-black overhead, and
// the kiln's glow with a haze around it.
float3 kilnSky(float3 d, constant Params& p) {
float up = pow(max(d.y, 0.0), 0.6);
float g = max(dot(d, GLOW), 0.0);
float glow = 0.5 * pow(g, 500.0) + 0.2 * pow(g, 30.0) + 0.07 * pow(g, 3.0);
float t = 0.6 - 0.75 * up + glow + (p.heat - 0.5) * 0.4;
return kiln(min(t, 0.92));
}
// How much water mirrors (Schlick's Fresnel): little seen from above,
// nearly all edge-on.
float mirror(float facing) {
return 0.02 + 0.98 * pow(1.0 - clamp(facing, 0.0, 1.0), 5.0);
}
// The water under the surface: dark, warmer on the crests.
float3 deepWater(float height) {
float3 lift = float3(0.06, 0.02, 0.01) * max(height, 0.0) * 20.0;
return float3(0.035, 0.018, 0.02) + lift;
}
// Once for every pixel: the sky mirrored as much as the angle allows, over
// the dark water below. Near the eye we look down into it; far off, almost
// edge-on, the water is a mirror.
float4 shade(Vertex v, constant Uniforms& u, constant Params& p) {
float3 n = normalize(v.normal);
float3 look = normalize(v.world - EYE);
float3 r = reflect(look, n);
r.y = abs(r.y);
float3 sky = kilnSky(r, p);
float3 col = mix(deepWater(v.world.y), sky, mirror(dot(-look, n)));
return float4(col, 1.0);
}
// Behind the water: the same sky, in the direction of each pixel.
float4 background(float2 uv, constant Uniforms& u, constant Params& p) {
float aspect = u.resolution.x / u.resolution.y;
float3x3 axes = cameraAxes();
float2 s = (uv * 2.0 - 1.0) / 1.7;
float3 d = normalize(axes[2] + axes[0] * s.x * aspect + axes[1] * s.y);
return float4(kilnSky(d, p), 1.0);
}5 lines from stage 3 that are gone
float lit = max(dot(n, GLOW), 0.0);
float far = smoothstep(2.0, 11.0, v.world.z);
float t = mix(0.15 + 3.0 * lit, 0.15, far) + (p.heat - 0.5) * 0.4;
return float4(kiln(t), 1.0);
⋯
return float4(kiln(0.3 - 0.25 * uv.y + (p.heat - 0.5) * 0.4), 1.0);Stage 5 · Rippling water
The last step hides the edges and finishes the water.
- Squares where they show.
cornerno longer spaces the grid evenly. Across,3 tan((x - 0.5) × 2.6)keeps the squares small in the middle and lets them grow toward the sides, out to about 11 units either way; ahead,16 y^1.7keeps them small near the eye and long far off, out to 15 units. The camera shrinks far squares on the screen anyway, so the same 320 by 320 squares cover a sheet wider and deeper than the view while staying fine where you look. - Distance. Past about 6 units,
shadeblends towardfarWater, the sky in a flat mirror;backgrounddraws the samefarWaterbelow the horizon, so the far edge never shows. - A glint and the rims. A sharp highlight where a ring's slope turns the glow straight to the eye, and the rims of the rings lit by the glow wherever they lean toward it, even where the mirror shows dark sky.
- The swell. Two long, low waves from the far side, crossing at an angle, roll under the rings, with their slopes added as the rings' are.
swellsets their height.
the kiln palette and shade(), from the finished shader
// Episode 1's kiln palette: near-black, ember, orange, gold, white-hot.
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));
}
// Once for every pixel of the water: the sky mirrored as much as the angle
// allows, a glint where a slope turns the glow to the eye, the rims of the
// rings lit by the glow, and calm far water in the distance.
float4 shade(Vertex v, constant Uniforms& u, constant Params& p) {
float3 n = normalize(v.normal);
float3 look = normalize(v.world - EYE);
float3 r = reflect(look, n);
r.y = abs(r.y);
float3 sky = kilnSky(r, p);
float3 col = mix(deepWater(v.world.y), sky, mirror(dot(-look, n)));
col += kiln(0.9) * pow(max(dot(r, GLOW), 0.0), 400.0) * 0.8;
float rim = max(dot(n, GLOW) - GLOW.y, 0.0);
col += kiln(0.45 + p.heat * 0.2) * rim * 3.0;
float far = smoothstep(6.0, 15.0, length(v.world - EYE));
return float4(mix(col, farWater(look, p), far), 1.0);
}episode-15-rippling-water.metal, the whole file58 new lines since stage 4
// Ray Kiln · Episode 15 · Rippling Water
//
// @episode 15 Rippling Water
// @length 60
// @variation 30
// @variations 1,2,4,5,6,9,10,11
// @still 14.0
// @teaches A vertex shader displacing a grid
// @category Vertex
// @tags vertex shaders, grids, displacement, ripples, normals, fresnel, reflections, procedural sound
// @final Rippling water | Drops ring the water, a slow swell rolls under them, and the surface mirrors a sky lit like a kiln.
//
// A sheet of water under a sky the colour of a kiln's mouth. Rain falls on it: every drop starts a ring that spreads,
// weakens and fades, the rings pass through each other, and a slow swell rolls under them all. This is the first
// episode drawn by a vertex shader. The host lays down a flat grid of squares, two triangles each; `corner` runs once
// for every corner of the grid and lifts it to the height of the water there, and `shade` colours the pixels between.
// 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
//
// @grid 320
// @param speed 1.0 0.0 3.0 How fast the rings spread and the swell rolls
// @param rain 1.6 0.3 5.0 Drops a second
// @param height 0.035 0.0 0.08 How tall the rings are
// @param size 0.3 0.15 0.7 The distance from one ring to the next
// @param swell 0.012 0.0 0.04 How tall the slow swell under the rings is
// @param heat 0.5 0.0 1.0 Slides the sky from deep ember to white-hot
// @param volume 0.8 0.0 1.0 Loudness of the sound
constant float TAU = 6.2831853;
// How many drops can be on their way at once.
constant int SLOTS = 7;
// The eye, low over the water, and the point it looks at.
constant float3 EYE = float3(0.0, 0.5, -1.2);
constant float3 AIM = float3(0.0, 0.2, 4.0);
// The kiln's glow: low in the sky, straight ahead.
constant float3 GLOW = float3(0.0, 0.09, 0.996);
// What each corner carries to the pixels between: where it lands on screen,
// where it is in the world, and which way the water faces there (its normal).
struct Vertex {
float4 position [[position]];
float3 world;
float3 normal;
};
// Episode 5's hash: a whole number in, a random 0 to 1 out.
float hashU(uint n) {
n = (n << 13u) ^ n;
n = n * (n * n * 15731u + 789221u) + 1376312589u;
return float(n & 0x7fffffffu) / float(0x7fffffff);
}
// Episode 1's kiln palette: near-black, ember, orange, gold, white-hot.
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));
}
// The camera's three directions: right, up and forward.
float3x3 cameraAxes() {
float3 f = normalize(AIM - EYE);
float3 r = normalize(cross(float3(0, 1, 0), f));
return float3x3(r, cross(f, r), f);
}
// A point in the world as the host wants it: x and y on screen (times
// the depth), the depth from 0 to 1 for the depth test, and the depth itself.
float4 project(float3 world, float aspect) {
float3x3 axes = cameraAxes();
float3 v = world - EYE;
float3 view = float3(dot(v, axes[0]), dot(v, axes[1]), dot(v, axes[2]));
float zoom = 1.7, near = 0.1, far = 30.0;
float depth = (view.z - near) / (far - near) * view.z;
return float4(view.x * zoom / aspect, view.y * zoom, depth, view.z);
}
// One drop: where it lands (x, z), when, and a random number of its own.
// Each slot drops once every SLOTS / rain seconds, at a moment of its own.
float4 drop(int slot, int cycle, constant Params& p) {
float period = float(SLOTS) / p.rain;
uint k = uint(slot) * 7919u + uint(cycle) * 104729u;
float jitter = (hashU(k + 3u) - 0.5) * 0.6;
float moment = (float(slot) + 0.5 + jitter) / float(SLOTS);
float z = 0.2 + pow(hashU(k + 2u), 1.4) * 5.5;
float x = (hashU(k + 1u) - 0.5) * (2.0 + 1.4 * z);
return float4(x, z, (float(cycle) + moment) * period, hashU(k + 4u));
}
// One drop's rings, a distance r from where it fell, `age` seconds after:
// a band of rings that runs outward, spreads and weakens. Returns the height
// and how fast it changes with r, worked out by hand from the same formula.
float2 rings(float r, float age, constant Params& p) {
float k = TAU / p.size;
float front = r - 0.55 * age;
float width = 0.18 + 0.22 * age;
float a = p.height * exp(-0.75 * age) / (1.0 + 2.0 * r);
a *= smoothstep(0.0, 0.12, age);
float band = exp(-front * front / (width * width));
float s = sin(k * front), c = cos(k * front);
float da = -2.0 * a / (1.0 + 2.0 * r);
float dband = -2.0 * front / (width * width) * band;
float dh = da * band * s + a * dband * s + a * band * k * c;
return float2(a * band * s, dh);
}
// The water at a point: its height (x) and its slope along x (y) and z (z).
// Every slot's last few drops add their rings; a long swell rolls under them.
float3 water(float2 xz, float t, constant Params& p) {
float3 w = float3(0.0);
float period = float(SLOTS) / p.rain;
for (int i = 0; i < SLOTS; i++) {
float slotTime = t / period - (float(i) + 0.5) / float(SLOTS);
int now = int(floor(slotTime));
for (int back = -1; back < 3; back++) {
int cycle = now - back;
if (cycle < 0) continue;
float4 d = drop(i, cycle, p);
float age = t - d.z;
if (age < 0.0 || age > 9.0) continue;
float2 to = xz - d.xy;
float r = max(length(to), 1e-4);
float2 hr = rings(r, age, p);
w += float3(hr.x, hr.y * to / r);
}
}
// The swell: two long waves from the far side, crossing at an angle.
float2 k1 = float2(0.35, -1.6), k2 = float2(-0.9, -1.1);
float s1 = dot(k1, xz) + 1.3 * t, s2 = dot(k2, xz) + 1.05 * t + 1.7;
w.x += p.swell * (sin(s1) + 0.6 * sin(s2));
w.yz += p.swell * (cos(s1) * k1 + 0.6 * cos(s2) * k2);
return w;
}
// Once for every corner: the grid's 0 to 1 becomes a sheet in front of the
// eye, lifted to the water's height. Its squares are small near the eye and
// grow far off, where the camera shrinks them anyway. The normal leans back
// against the slope.
Vertex corner(float2 grid, constant Uniforms& u, constant Params& p) {
float x = 3.0 * tan((grid.x - 0.5) * 2.6);
float z = 16.0 * pow(grid.y, 1.7) - 0.6;
float3 w = water(float2(x, z), u.time * p.speed, p);
Vertex o;
o.world = float3(x, w.x, z);
o.position = project(o.world, u.resolution.x / u.resolution.y);
o.normal = normalize(float3(-w.y, 1.0, -w.z));
return o;
}
// The sky in a direction: gold at the horizon, near-black overhead, and
// the kiln's glow with a haze around it.
float3 kilnSky(float3 d, constant Params& p) {
float up = pow(max(d.y, 0.0), 0.6);
float g = max(dot(d, GLOW), 0.0);
float glow = 0.5 * pow(g, 500.0) + 0.2 * pow(g, 30.0) + 0.07 * pow(g, 3.0);
float t = 0.6 - 0.75 * up + glow + (p.heat - 0.5) * 0.4;
return kiln(min(t, 0.92));
}
// How much water mirrors (Schlick's Fresnel): little seen from above,
// nearly all edge-on.
float mirror(float facing) {
return 0.02 + 0.98 * pow(1.0 - clamp(facing, 0.0, 1.0), 5.0);
}
// The water under the surface: dark, warmer on the crests.
float3 deepWater(float height) {
float3 lift = float3(0.06, 0.02, 0.01) * max(height, 0.0) * 20.0;
return float3(0.035, 0.018, 0.02) + lift;
}
// Calm water far away in a direction: the sky in a flat mirror.
float3 farWater(float3 d, constant Params& p) {
float3 r = float3(d.x, abs(d.y), d.z);
return mix(deepWater(0.0), kilnSky(r, p), mirror(abs(d.y)));
}
// Once for every pixel of the water: the sky mirrored as much as the angle
// allows, a glint where a slope turns the glow to the eye, the rims of the
// rings lit by the glow, and calm far water in the distance.
float4 shade(Vertex v, constant Uniforms& u, constant Params& p) {
float3 n = normalize(v.normal);
float3 look = normalize(v.world - EYE);
float3 r = reflect(look, n);
r.y = abs(r.y);
float3 sky = kilnSky(r, p);
float3 col = mix(deepWater(v.world.y), sky, mirror(dot(-look, n)));
col += kiln(0.9) * pow(max(dot(r, GLOW), 0.0), 400.0) * 0.8;
float rim = max(dot(n, GLOW) - GLOW.y, 0.0);
col += kiln(0.45 + p.heat * 0.2) * rim * 3.0;
float far = smoothstep(6.0, 15.0, length(v.world - EYE));
return float4(mix(col, farWater(look, p), far), 1.0);
}
// Behind the water: the sky above the horizon, and calm far water below it,
// past the sheet's far edge, so the edge never shows.
float4 background(float2 uv, constant Uniforms& u, constant Params& p) {
float aspect = u.resolution.x / u.resolution.y;
float3x3 axes = cameraAxes();
float2 s = (uv * 2.0 - 1.0) / 1.7;
float3 d = normalize(axes[2] + axes[0] * s.x * aspect + axes[1] * s.y);
return float4(d.y > 0.0 ? kilnSky(d, p) : farWater(d, p), 1.0);
}
// ---- Sound -----------------------------------------------------------------------------------------------------
// The sound is part of the shader: a function of time, run on the GPU 48,000 times a second and put in the video.
// Noise made by joining random values `rate` times a second: a lower rate sounds darker.
float noise1(float t, float rate, uint salt) {
float x = t * rate;
float i = floor(x);
float f = x - i;
f = f * f * (3.0 - 2.0 * f);
uint n = uint(int(i) + 1000000);
return mix(hashU(n * 31u + salt), hashU((n + 1u) * 31u + salt), f);
}
// Every ring starts with a plink: the same drops, at the same moments. A drop's
// note is from a D minor pentatonic scale, rising fast as the bubble it traps
// closes, over a soft thud, panned to where it lands. Under them the water
// laps: smooth noise that swells with the swell.
float2 sound(float t, constant Params& p) {
float notes[6] = { 587.33, 698.46, 783.99, 880.0, 1046.5, 1174.66 };
float tt = t * p.speed;
float period = float(SLOTS) / p.rain;
float2 v = float2(0.0);
for (int i = 0; i < SLOTS; i++) {
float slotTime = tt / period - (float(i) + 0.5) / float(SLOTS);
int now = int(floor(slotTime));
for (int back = -1; back < 2; back++) {
int cycle = now - back;
if (cycle < 0) continue;
float4 d = drop(i, cycle, p);
// Seconds of sound since it fell: the plink keeps its own pace.
float age = (tt - d.z) / max(p.speed, 0.05);
if (age < 0.0 || age > 1.2) continue;
float f0 = notes[int(d.w * 5.99)];
float attack = 1.0 - exp(-age / 0.002);
float rise = age + 2.0 * age * age;
float ring = sin(TAU * f0 * rise) * exp(-age / 0.09);
float thud = sin(TAU * 140.0 * age) * exp(-age / 0.03) * 0.5;
float loud = 0.5 + 0.5 * hashU(uint(cycle) * 131u + uint(i));
loud *= 0.55 * (0.6 + 0.5 / (1.0 + 0.4 * d.y));
float pan = clamp(0.5 + d.x / 6.0, 0.1, 0.9);
v += float2(1.0 - pan, pan) * (ring + thud) * attack * loud;
}
}
float lap = noise1(t, 320.0, 3u) - 0.5;
lap += 0.5 * (noise1(t, 1100.0, 9u) - 0.5);
float swellNow = 0.5 + 0.5 * sin(1.3 * tt);
float gentle = 0.25 + (0.5 + 2.0 * p.swell / 0.04) * swellNow * 0.3;
float lean = noise1(t, 0.25, 23u);
v += float2(1.0 - lean * 0.5, 0.5 + lean * 0.5) * lap * 0.6 * gentle;
float fadeIn = smoothstep(0.0, 2.0, t);
return tanh(v * 2.5) / 1.45 * fadeIn * p.volume;
}4 lines from stage 4 that are gone
float x = (grid.x - 0.5) * 12.0;
float z = grid.y * 12.0 - 0.6;
⋯
return float4(col, 1.0);
⋯
return float4(kilnSky(d, p), 1.0);The sound
The sound reads the same drop as the picture, so every ring starts with a plink at the moment it appears. A drop's note is one of a D minor pentatonic scale (D, F, G, A, C and D again, from the fifth octave up), chosen by its own random number. The note rises quickly after it starts, sin(2π f × (age + 2 age²)), the way the bubble a raindrop traps sings higher as it closes, and fades in about a tenth of a second, over a soft 140 Hz thud. Each plink is panned to where its drop lands, and nearer drops are louder.
Under the plinks the water laps: smooth noise joined 320 and 1,100 times a second, swelling and settling with the swell. speed moves the drops in time without changing how a plink sounds, rain sets how many there are, and swell sets how much the lapping swells. Everything goes through tanh and fades in over the first two seconds. The levels at the defaults are a peak of about -6.5 dBFS and an average (RMS) of about -21 dBFS.
New in Ray Kiln
Wireframe, in the playground and in the Mac app. A vertex shader's picture is made of triangles, and now you can see them: the Wireframe button under the picture (it appears for a shader with a // @grid N line) draws every triangle's three edges as cream lines over the surface, where the surface doesn't hide them. An edge only a few pixels long on the screen fades out, so a fine grid shows as a light haze rather than a solid sheet of lines. In the app it is Shader > Show Wireframe, which comes with the app's next release, and raykiln render … --wireframe writes a still with the lines. Exports and recordings stay solid; a still has the lines only when you ask for them.
Try it on stage 1: each tile of the floor is one square of the grid, cut corner to corner into its two triangles. On the finished water the squares are small, so try it with the picture large (Full screen), or change // @grid 320 to // @grid 64 to count them: the lines show where the corners went, close together near the eye and in the middle and stretched long toward the far side, where the camera makes them small anyway, and each ring as rows of corners lifted and lowered.
Try this
- Set
rainto 5 for a downpour of crossing rings, or to 0.3 for a single drop at a time. - Set
sizeto 0.15 for fine, quick rings, or to 0.7 for long, slow ones. - Set
heightto 0 and the water is still: only the swell moves it, and the glow lies on it as a bright band. - Set
swellto 0.04 for a lake on a windy day. - In
corner, putfloat x = (grid.x - 0.5) * 12.0;andfloat z = grid.y * 12.0 - 0.6;back from stage 1 and turn on Wireframe: the squares are all the same size again, and the sheet's sides and far edge come back into view.
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 | 1.0 | 0.0 to 3.0 | How fast the rings spread and the swell rolls |
rain | 1.6 | 0.3 to 5.0 | Drops a second |
height | 0.035 | 0.0 to 0.08 | How tall the rings are |
size | 0.3 | 0.15 to 0.7 | The distance from one ring to the next |
swell | 0.012 | 0.0 to 0.04 | How tall the slow swell under the rings is |
heat | 0.5 | 0.0 to 1.0 | Slides the sky 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-15-rippling-water.metal
// Ray Kiln · Episode 15 · Rippling Water
//
// @episode 15 Rippling Water
// @length 60
// @variation 30
// @variations 1,2,4,5,6,9,10,11
// @still 14.0
// @teaches A vertex shader displacing a grid
// @category Vertex
// @tags vertex shaders, grids, displacement, ripples, normals, fresnel, reflections, procedural sound
// @final Rippling water | Drops ring the water, a slow swell rolls under them, and the surface mirrors a sky lit like a kiln.
//
// A sheet of water under a sky the colour of a kiln's mouth. Rain falls on it: every drop starts a ring that spreads,
// weakens and fades, the rings pass through each other, and a slow swell rolls under them all. This is the first
// episode drawn by a vertex shader. The host lays down a flat grid of squares, two triangles each; `corner` runs once
// for every corner of the grid and lifts it to the height of the water there, and `shade` colours the pixels between.
// 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
//
// @grid 320
// @param speed 1.0 0.0 3.0 How fast the rings spread and the swell rolls
// @param rain 1.6 0.3 5.0 Drops a second
// @param height 0.035 0.0 0.08 How tall the rings are
// @param size 0.3 0.15 0.7 The distance from one ring to the next
// @param swell 0.012 0.0 0.04 How tall the slow swell under the rings is
// @param heat 0.5 0.0 1.0 Slides the sky from deep ember to white-hot
// @param volume 0.8 0.0 1.0 Loudness of the sound
constant float TAU = 6.2831853;
// How many drops can be on their way at once.
constant int SLOTS = 7;
// The eye, low over the water, and the point it looks at.
constant float3 EYE = float3(0.0, 0.5, -1.2);
constant float3 AIM = float3(0.0, 0.2, 4.0);
// The kiln's glow: low in the sky, straight ahead.
constant float3 GLOW = float3(0.0, 0.09, 0.996);
// What each corner carries to the pixels between: where it lands on screen,
// where it is in the world, and which way the water faces there (its normal).
struct Vertex {
float4 position [[position]];
float3 world;
float3 normal;
};
// Episode 5's hash: a whole number in, a random 0 to 1 out.
float hashU(uint n) {
n = (n << 13u) ^ n;
n = n * (n * n * 15731u + 789221u) + 1376312589u;
return float(n & 0x7fffffffu) / float(0x7fffffff);
}
// Episode 1's kiln palette: near-black, ember, orange, gold, white-hot.
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));
}
// The camera's three directions: right, up and forward.
float3x3 cameraAxes() {
float3 f = normalize(AIM - EYE);
float3 r = normalize(cross(float3(0, 1, 0), f));
return float3x3(r, cross(f, r), f);
}
// A point in the world as the host wants it: x and y on screen (times
// the depth), the depth from 0 to 1 for the depth test, and the depth itself.
float4 project(float3 world, float aspect) {
float3x3 axes = cameraAxes();
float3 v = world - EYE;
float3 view = float3(dot(v, axes[0]), dot(v, axes[1]), dot(v, axes[2]));
float zoom = 1.7, near = 0.1, far = 30.0;
float depth = (view.z - near) / (far - near) * view.z;
return float4(view.x * zoom / aspect, view.y * zoom, depth, view.z);
}
// One drop: where it lands (x, z), when, and a random number of its own.
// Each slot drops once every SLOTS / rain seconds, at a moment of its own.
float4 drop(int slot, int cycle, constant Params& p) {
float period = float(SLOTS) / p.rain;
uint k = uint(slot) * 7919u + uint(cycle) * 104729u;
float jitter = (hashU(k + 3u) - 0.5) * 0.6;
float moment = (float(slot) + 0.5 + jitter) / float(SLOTS);
float z = 0.2 + pow(hashU(k + 2u), 1.4) * 5.5;
float x = (hashU(k + 1u) - 0.5) * (2.0 + 1.4 * z);
return float4(x, z, (float(cycle) + moment) * period, hashU(k + 4u));
}
// One drop's rings, a distance r from where it fell, `age` seconds after:
// a band of rings that runs outward, spreads and weakens. Returns the height
// and how fast it changes with r, worked out by hand from the same formula.
float2 rings(float r, float age, constant Params& p) {
float k = TAU / p.size;
float front = r - 0.55 * age;
float width = 0.18 + 0.22 * age;
float a = p.height * exp(-0.75 * age) / (1.0 + 2.0 * r);
a *= smoothstep(0.0, 0.12, age);
float band = exp(-front * front / (width * width));
float s = sin(k * front), c = cos(k * front);
float da = -2.0 * a / (1.0 + 2.0 * r);
float dband = -2.0 * front / (width * width) * band;
float dh = da * band * s + a * dband * s + a * band * k * c;
return float2(a * band * s, dh);
}
// The water at a point: its height (x) and its slope along x (y) and z (z).
// Every slot's last few drops add their rings; a long swell rolls under them.
float3 water(float2 xz, float t, constant Params& p) {
float3 w = float3(0.0);
float period = float(SLOTS) / p.rain;
for (int i = 0; i < SLOTS; i++) {
float slotTime = t / period - (float(i) + 0.5) / float(SLOTS);
int now = int(floor(slotTime));
for (int back = -1; back < 3; back++) {
int cycle = now - back;
if (cycle < 0) continue;
float4 d = drop(i, cycle, p);
float age = t - d.z;
if (age < 0.0 || age > 9.0) continue;
float2 to = xz - d.xy;
float r = max(length(to), 1e-4);
float2 hr = rings(r, age, p);
w += float3(hr.x, hr.y * to / r);
}
}
// The swell: two long waves from the far side, crossing at an angle.
float2 k1 = float2(0.35, -1.6), k2 = float2(-0.9, -1.1);
float s1 = dot(k1, xz) + 1.3 * t, s2 = dot(k2, xz) + 1.05 * t + 1.7;
w.x += p.swell * (sin(s1) + 0.6 * sin(s2));
w.yz += p.swell * (cos(s1) * k1 + 0.6 * cos(s2) * k2);
return w;
}
// Once for every corner: the grid's 0 to 1 becomes a sheet in front of the
// eye, lifted to the water's height. Its squares are small near the eye and
// grow far off, where the camera shrinks them anyway. The normal leans back
// against the slope.
Vertex corner(float2 grid, constant Uniforms& u, constant Params& p) {
float x = 3.0 * tan((grid.x - 0.5) * 2.6);
float z = 16.0 * pow(grid.y, 1.7) - 0.6;
float3 w = water(float2(x, z), u.time * p.speed, p);
Vertex o;
o.world = float3(x, w.x, z);
o.position = project(o.world, u.resolution.x / u.resolution.y);
o.normal = normalize(float3(-w.y, 1.0, -w.z));
return o;
}
// The sky in a direction: gold at the horizon, near-black overhead, and
// the kiln's glow with a haze around it.
float3 kilnSky(float3 d, constant Params& p) {
float up = pow(max(d.y, 0.0), 0.6);
float g = max(dot(d, GLOW), 0.0);
float glow = 0.5 * pow(g, 500.0) + 0.2 * pow(g, 30.0) + 0.07 * pow(g, 3.0);
float t = 0.6 - 0.75 * up + glow + (p.heat - 0.5) * 0.4;
return kiln(min(t, 0.92));
}
// How much water mirrors (Schlick's Fresnel): little seen from above,
// nearly all edge-on.
float mirror(float facing) {
return 0.02 + 0.98 * pow(1.0 - clamp(facing, 0.0, 1.0), 5.0);
}
// The water under the surface: dark, warmer on the crests.
float3 deepWater(float height) {
float3 lift = float3(0.06, 0.02, 0.01) * max(height, 0.0) * 20.0;
return float3(0.035, 0.018, 0.02) + lift;
}
// Calm water far away in a direction: the sky in a flat mirror.
float3 farWater(float3 d, constant Params& p) {
float3 r = float3(d.x, abs(d.y), d.z);
return mix(deepWater(0.0), kilnSky(r, p), mirror(abs(d.y)));
}
// Once for every pixel of the water: the sky mirrored as much as the angle
// allows, a glint where a slope turns the glow to the eye, the rims of the
// rings lit by the glow, and calm far water in the distance.
float4 shade(Vertex v, constant Uniforms& u, constant Params& p) {
float3 n = normalize(v.normal);
float3 look = normalize(v.world - EYE);
float3 r = reflect(look, n);
r.y = abs(r.y);
float3 sky = kilnSky(r, p);
float3 col = mix(deepWater(v.world.y), sky, mirror(dot(-look, n)));
col += kiln(0.9) * pow(max(dot(r, GLOW), 0.0), 400.0) * 0.8;
float rim = max(dot(n, GLOW) - GLOW.y, 0.0);
col += kiln(0.45 + p.heat * 0.2) * rim * 3.0;
float far = smoothstep(6.0, 15.0, length(v.world - EYE));
return float4(mix(col, farWater(look, p), far), 1.0);
}
// Behind the water: the sky above the horizon, and calm far water below it,
// past the sheet's far edge, so the edge never shows.
float4 background(float2 uv, constant Uniforms& u, constant Params& p) {
float aspect = u.resolution.x / u.resolution.y;
float3x3 axes = cameraAxes();
float2 s = (uv * 2.0 - 1.0) / 1.7;
float3 d = normalize(axes[2] + axes[0] * s.x * aspect + axes[1] * s.y);
return float4(d.y > 0.0 ? kilnSky(d, p) : farWater(d, p), 1.0);
}
// ---- Sound -----------------------------------------------------------------------------------------------------
// The sound is part of the shader: a function of time, run on the GPU 48,000 times a second and put in the video.
// Noise made by joining random values `rate` times a second: a lower rate sounds darker.
float noise1(float t, float rate, uint salt) {
float x = t * rate;
float i = floor(x);
float f = x - i;
f = f * f * (3.0 - 2.0 * f);
uint n = uint(int(i) + 1000000);
return mix(hashU(n * 31u + salt), hashU((n + 1u) * 31u + salt), f);
}
// Every ring starts with a plink: the same drops, at the same moments. A drop's
// note is from a D minor pentatonic scale, rising fast as the bubble it traps
// closes, over a soft thud, panned to where it lands. Under them the water
// laps: smooth noise that swells with the swell.
float2 sound(float t, constant Params& p) {
float notes[6] = { 587.33, 698.46, 783.99, 880.0, 1046.5, 1174.66 };
float tt = t * p.speed;
float period = float(SLOTS) / p.rain;
float2 v = float2(0.0);
for (int i = 0; i < SLOTS; i++) {
float slotTime = tt / period - (float(i) + 0.5) / float(SLOTS);
int now = int(floor(slotTime));
for (int back = -1; back < 2; back++) {
int cycle = now - back;
if (cycle < 0) continue;
float4 d = drop(i, cycle, p);
// Seconds of sound since it fell: the plink keeps its own pace.
float age = (tt - d.z) / max(p.speed, 0.05);
if (age < 0.0 || age > 1.2) continue;
float f0 = notes[int(d.w * 5.99)];
float attack = 1.0 - exp(-age / 0.002);
float rise = age + 2.0 * age * age;
float ring = sin(TAU * f0 * rise) * exp(-age / 0.09);
float thud = sin(TAU * 140.0 * age) * exp(-age / 0.03) * 0.5;
float loud = 0.5 + 0.5 * hashU(uint(cycle) * 131u + uint(i));
loud *= 0.55 * (0.6 + 0.5 / (1.0 + 0.4 * d.y));
float pan = clamp(0.5 + d.x / 6.0, 0.1, 0.9);
v += float2(1.0 - pan, pan) * (ring + thud) * attack * loud;
}
}
float lap = noise1(t, 320.0, 3u) - 0.5;
lap += 0.5 * (noise1(t, 1100.0, 9u) - 0.5);
float swellNow = 0.5 + 0.5 * sin(1.3 * tt);
float gentle = 0.25 + (0.5 + 2.0 * p.swell / 0.04) * swellNow * 0.3;
float lean = noise1(t, 0.25, 23u);
v += float2(1.0 - lean * 0.5, 0.5 + lean * 0.5) * lap * 0.6 * gentle;
float fadeIn = smoothstep(0.0, 2.0, t);
return tanh(v * 2.5) / 1.45 * 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] = [1.0, 1.6, 0.035, 0.3, 0.012, 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.
Press ? for keyboard shortcuts.