Shader

Ocean Waves

An open sea at dusk under a sky lit like a kiln's mouth. Eight Gerstner waves move every corner of a 384 by 384 grid in circles, so the crests sharpen and the troughs widen as real swell does; where they add up and the surface bunches together, foam gathers, and the water mirrors the glowing sky as much as its angle allows. The surf you hear is the same sea, heard at three points ahead.

· Geometry · Vertex shader

Teaches Gerstner waves on a grid, foam on the crests, the sky in the water

Builds on Flowing Color Field (hash21, value noise), Cracked Clay (integer hash, sound noise), Rippling Water (kiln sky, Fresnel mirror, the camera's axes, perspective projection), Flag in the Wind, Crystal Bloom

Watch

The idea

A vertex episode starts from a grid the host lays down, as Rippling Water and Flag in the Wind do, and corner moves every corner of it. Here the grid is a sheet of sea in front of the eye: its 0 to 1 across becomes 6 tan(…), so the sheet spreads wide at the sides, and its 0 to 1 ahead becomes 70 y^1.9, so the squares are small near the eye and long far off, where each one covers less of the screen. The eye is 2.4 units over the water, looking a little down toward the kiln's glow, low on the horizon straight ahead.

Real swell isn't a sine wave. Each bit of water goes round in a circle as a wave passes: up and forward on the crest, down and back in the trough. That is a Gerstner wave, and it is why crests are sharp and troughs are wide. Add up eight of them, long and short, each heading its own way, and you have a sea. The same sums say where the surface bunches up, which is where foam gathers. It is built in five steps.

Stage 1 · One wave

The grid laid out as the sheet of sea, and one plain sine wave across it, 8 units from crest to crest, rolling toward the eye at a slant. corner lifts each corner by A sin(phase), where the phase is k times the distance along the wave's heading less omega t. omega is sqrt(9.8 k) (times 0.8 to slow it a little): that is how fast deep-water waves of that length really move, so long waves outrun short ones. The slope of the sine, k A cos(phase), gives the normal, and shade lights each slope from the glow: the ones that turn toward it are bright, the ones that turn away are dark. project is the camera, as in Rippling Water: x and y on screen times the depth, and the depth itself, which the host divides by.

stage-1-one-wave.metal
// Stage 1 · One wave
// A grid of 384 by 384 squares laid out as a sheet of sea, small squares
// near the eye and long ones far off. corner lifts every corner by a sine
// wave rolling in from the glow, and the slope of the sine gives the normal.
//
// @grid 384

// @param speed 1.0 0.0 2.5 How fast the waves roll
// @param height 1.0 0.0 2.0 How tall the waves are
// @param heat 0.5 0.0 1.0 Slides the sky from deep ember to white-hot

constant float TAU = 6.2831853;
// The eye, a little over the water, and the point it looks at.
constant float3 EYE = float3(0.0, 2.4, -2.0);
constant float3 AIM = float3(0.0, 1.3, 10.0);
// The kiln's glow: low in the sky, straight ahead.
constant float3 GLOW = float3(0.0, 0.07, 0.9975);

// 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.
struct Vertex {
    float4 position [[position]];
    float3 world;
    float3 normal;
};

// Flowing Color Field's 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.5, near = 0.1, far = 120.0;
    float depth = (view.z - near) / (far - near) * view.z;
    return float4(view.x * zoom / aspect, view.y * zoom, depth, view.z);
}

// 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));
}

// Once for every corner: one wave, 8 units from crest to crest, rolling
// toward the eye at a slant. Its height is A sin(phase); its slope along
// the heading is k A cos(phase), and the normal leans back against it.
Vertex corner(float2 grid, constant Uniforms& u, constant Params& p) {
    float x = 6.0 * tan((grid.x - 0.5) * 2.7);
    float z = 70.0 * pow(grid.y, 1.9) - 1.0;
    float2 d = normalize(float2(0.35, -1.0));
    float k = TAU / 8.0;
    float a = p.height * 0.3 / k;
    float omega = sqrt(9.8 * k) * 0.8;
    float phase = k * dot(d, float2(x, z)) - omega * u.time * p.speed;
    float h = a * sin(phase);
    float slope = k * a * cos(phase);
    Vertex o;
    o.world = float3(x, h, z);
    o.position = project(o.world, u.resolution.x / u.resolution.y);
    o.normal = normalize(float3(-d.x * slope, 1.0, -d.y * slope));
    return o;
}

// Once for every pixel: lit from ahead and a little above the glow. A slope
// that turns toward the light is bright, one that turns away is dark, and
// the sea fades far off.
float4 shade(Vertex v, constant Uniforms& u, constant Params& p) {
    float3 n = normalize(v.normal);
    float lit = max(dot(n, normalize(float3(0.0, 0.35, 1.0))), 0.0);
    float far = smoothstep(20.0, 60.0, length(v.world - EYE));
    float t = mix(0.06 + 0.9 * pow(lit, 1.5), 0.45, far);
    t += (p.heat - 0.5) * 0.3;
    return float4(kiln(t), 1.0);
}

// Behind the sea: the sky, and a plain dark below the horizon.
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.5;
    float3 d = normalize(axes[2] + axes[0] * s.x * aspect + axes[1] * s.y);
    float3 low = kiln(0.45 + (p.heat - 0.5) * 0.3);
    return float4(d.y > 0.0 ? kilnSky(d, p) : low, 1.0);
}

Stage 2 · A Gerstner wave

The same wave, but each corner also moves along the heading by Q A cos(phase), so it turns in a circle instead of only bobbing. The crests bunch together and sharpen and the troughs spread out. Q k A is how hard a crest pinches, and choppy sets it: at 0 the wave is the sine of stage 1, at 1 the crest comes to a point, and past 1 the surface would fold through itself, so the slider stops there. The normal now has a part from the circling too, 1 - Q k A sin(phase) upward.

stage-2-gerstner.metal5 new lines since stage 1
// Stage 2 · A Gerstner wave
// The same wave, but each point of the water turns in a circle: up and
// forward on the crest, down and back in the trough. The crests bunch up
// and sharpen and the troughs widen, as real swell does. choppy sets how far.
//
// @grid 384

// @param speed 1.0 0.0 2.5 How fast the waves roll
// @param height 1.0 0.0 2.0 How tall the waves are
// @param choppy 0.85 0.0 1.0 How sharp the crests are: 0 rolls smoothly, 1 pinches them to points
// @param heat 0.5 0.0 1.0 Slides the sky from deep ember to white-hot

constant float TAU = 6.2831853;
// The eye, a little over the water, and the point it looks at.
constant float3 EYE = float3(0.0, 2.4, -2.0);
constant float3 AIM = float3(0.0, 1.3, 10.0);
// The kiln's glow: low in the sky, straight ahead.
constant float3 GLOW = float3(0.0, 0.07, 0.9975);

// 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.
struct Vertex {
    float4 position [[position]];
    float3 world;
    float3 normal;
};

// Flowing Color Field's 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.5, near = 0.1, far = 120.0;
    float depth = (view.z - near) / (far - near) * view.z;
    return float4(view.x * zoom / aspect, view.y * zoom, depth, view.z);
}

// 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));
}

// Once for every corner: a Gerstner wave. The point rises by A sin(phase)
// as before, and moves along the heading by Q A cos(phase), so it turns in
// a circle. Q k A is how hard the crest pinches: at 1 it comes to a point,
// past 1 the surface would fold through itself.
Vertex corner(float2 grid, constant Uniforms& u, constant Params& p) {
    float x = 6.0 * tan((grid.x - 0.5) * 2.7);
    float z = 70.0 * pow(grid.y, 1.9) - 1.0;
    float2 d = normalize(float2(0.35, -1.0));
    float k = TAU / 8.0;
    float a = p.height * 0.3 / k;
    float q = p.choppy / (k * max(a, 1e-4));
    float omega = sqrt(9.8 * k) * 0.8;
    float phase = k * dot(d, float2(x, z)) - omega * u.time * p.speed;
    float s = sin(phase), c = cos(phase);
    Vertex o;
    o.world = float3(x + q * a * d.x * c, a * s, z + q * a * d.y * c);
    o.position = project(o.world, u.resolution.x / u.resolution.y);
    o.normal = normalize(float3(-d.x * k * a * c, 1.0 - q * k * a * s,
                                -d.y * k * a * c));
    return o;
}

// Once for every pixel: lit from ahead and a little above the glow. A slope
// that turns toward the light is bright, one that turns away is dark, and
// the sea fades far off.
float4 shade(Vertex v, constant Uniforms& u, constant Params& p) {
    float3 n = normalize(v.normal);
    float lit = max(dot(n, normalize(float3(0.0, 0.35, 1.0))), 0.0);
    float far = smoothstep(20.0, 60.0, length(v.world - EYE));
    float t = mix(0.06 + 0.9 * pow(lit, 1.5), 0.45, far);
    t += (p.heat - 0.5) * 0.3;
    return float4(kiln(t), 1.0);
}

// Behind the sea: the sky, and a plain dark below the horizon.
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.5;
    float3 d = normalize(axes[2] + axes[0] * s.x * aspect + axes[1] * s.y);
    float3 low = kiln(0.45 + (p.heat - 0.5) * 0.3);
    return float4(d.y > 0.0 ? kilnSky(d, p) : low, 1.0);
}
4 lines from stage 1 that are gone
    float h = a * sin(phase);
    float slope = k * a * cos(phase);
⋯
    o.world = float3(x, h, z);
⋯
    o.normal = normalize(float3(-d.x * slope, 1.0, -d.y * slope));

Stage 3 · Eight waves

One wave looks like a ploughed field. sea adds up eight, and waveOf gives each its heading and its length: 14 units for the first, and each next one two thirds as long, down to under one. The headings fan out around the wind's, and wind turns them all left or right. Each wave gets an eighth of choppy, so however they line up, together they never pinch past 1. Short waves fade out with the distance, from 6 to 14 of their own lengths away, because out there a square of the grid is longer than the wave and would draw it as noise.

stage-3-eight-waves.metal33 new lines since stage 2
// Stage 3 · Eight waves
// One wave is a ploughed field. The sea is many at once: eight Gerstner
// waves, from 14 units long down to under one, each with a heading of its
// own fanned around the wind, added up at every corner.
//
// @grid 384

// @param speed 1.0 0.0 2.5 How fast the waves roll
// @param height 1.0 0.0 2.0 How tall the waves are
// @param choppy 0.85 0.0 1.0 How sharp the crests are: 0 rolls smoothly, 1 pinches them to points
// @param wind 0.0 -1.0 1.0 Turns the waves' heading left or right
// @param heat 0.5 0.0 1.0 Slides the sky from deep ember to white-hot

constant float TAU = 6.2831853;
// The eye, a little over the water, and the point it looks at.
constant float3 EYE = float3(0.0, 2.4, -2.0);
constant float3 AIM = float3(0.0, 1.3, 10.0);
// The kiln's glow: low in the sky, straight ahead.
constant float3 GLOW = float3(0.0, 0.07, 0.9975);
// How many waves add up to the sea.
constant int WAVES = 8;

// 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.
struct Vertex {
    float4 position [[position]];
    float3 world;
    float3 normal;
};

// Flowing Color Field's 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.5, near = 0.1, far = 120.0;
    float depth = (view.z - near) / (far - near) * view.z;
    return float4(view.x * zoom / aspect, view.y * zoom, depth, view.z);
}

// 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));
}

// Wave i: its heading (x, z, one unit long) and its length, from 14 units
// down to under one. The headings fan out around the wind's.
float3 waveOf(int i, constant Params& p) {
    float fan[8] = { 0.0, 0.5, -0.45, 0.85, -0.8, 0.3, -1.15, 1.3 };
    float a = fan[i] + p.wind * 0.9;
    return float3(sin(a), -cos(a), 14.0 * pow(0.66, float(i)));
}

// The sea at a point of the still surface: eight Gerstner waves added up.
// Each pinches its crests by an eighth of choppy, so all eight together
// never fold the surface. Short waves fade with the distance, where the
// grid's squares are too long to draw them. n gets the normal.
float3 sea(float2 xz, float t, constant Params& p, thread float3& n) {
    float3 at = float3(xz.x, 0.0, xz.y);
    float3 slope = float3(0.0, 1.0, 0.0);
    float far = length(xz - EYE.xz);
    for (int i = 0; i < WAVES; i++) {
        float3 w = waveOf(i, p);
        float2 d = w.xy;
        float k = TAU / w.z;
        float fade = 1.0 - smoothstep(w.z * 6.0, w.z * 14.0, far);
        float a = p.height * 0.12 / k * fade;
        float q = p.choppy / (k * max(a, 1e-4) * float(WAVES)) * fade;
        float omega = sqrt(9.8 * k) * 0.8;
        float phase = k * dot(d, xz) - omega * t + float(i) * 1.7;
        float s = sin(phase), c = cos(phase);
        at += float3(q * a * d.x * c, a * s, q * a * d.y * c);
        slope.x -= d.x * k * a * c;
        slope.z -= d.y * k * a * c;
        slope.y -= q * k * a * s;
    }
    n = normalize(slope);
    return at;
}

// Once for every corner: wherever the eight waves take it.
Vertex corner(float2 grid, constant Uniforms& u, constant Params& p) {
    float x = 6.0 * tan((grid.x - 0.5) * 2.7);
    float z = 70.0 * pow(grid.y, 1.9) - 1.0;
    float3 n;
    float3 world = sea(float2(x, z), u.time * p.speed, p, n);
    Vertex o;
    o.world = world;
    o.position = project(world, u.resolution.x / u.resolution.y);
    o.normal = n;
    return o;
}

// Once for every pixel: lit from ahead and a little above the glow. A slope
// that turns toward the light is bright, one that turns away is dark, and
// the sea fades far off.
float4 shade(Vertex v, constant Uniforms& u, constant Params& p) {
    float3 n = normalize(v.normal);
    float lit = max(dot(n, normalize(float3(0.0, 0.35, 1.0))), 0.0);
    float far = smoothstep(20.0, 60.0, length(v.world - EYE));
    float t = mix(0.06 + 0.9 * pow(lit, 1.5), 0.45, far);
    t += (p.heat - 0.5) * 0.3;
    return float4(kiln(t), 1.0);
}

// Behind the sea: the sky, and a plain dark below the horizon.
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.5;
    float3 d = normalize(axes[2] + axes[0] * s.x * aspect + axes[1] * s.y);
    float3 low = kiln(0.45 + (p.heat - 0.5) * 0.3);
    return float4(d.y > 0.0 ? kilnSky(d, p) : low, 1.0);
}
11 lines from stage 2 that are gone
    float2 d = normalize(float2(0.35, -1.0));
    float k = TAU / 8.0;
    float a = p.height * 0.3 / k;
    float q = p.choppy / (k * max(a, 1e-4));
    float omega = sqrt(9.8 * k) * 0.8;
    float phase = k * dot(d, float2(x, z)) - omega * u.time * p.speed;
    float s = sin(phase), c = cos(phase);
⋯
    o.world = float3(x + q * a * d.x * c, a * s, z + q * a * d.y * c);
    o.position = project(o.world, u.resolution.x / u.resolution.y);
    o.normal = normalize(float3(-d.x * k * a * c, 1.0 - q * k * a * s,
                                -d.y * k * a * c));

Stage 4 · The sky in the water

Water shows mostly what it reflects. shade bounces the eye's ray off the surface with reflect and looks up the sky in that direction (kilnSky: gold at the horizon, near-black overhead, and the glow with a haze round it). How much of the sky it shows is Schlick's Fresnel in mirror: 2% looking straight down, nearly all of it edge-on, and the rest is the deep wine colour of the water under the surface. A glint is added where a slope turns the glow straight to the eye. Far off, the sea turns into farSea, the sky in a flat mirror, and the background below the horizon is the same, so the sheet's far edge never shows.

stage-4-sky.metal18 new lines since stage 3
// Stage 4 · The sky in the water
// Water shows mostly what it reflects. shade bounces the eye's ray off the
// surface into the kiln's sky, and Fresnel says how much: little looking
// down, nearly all toward the horizon. Far off it turns to calm sea.
//
// @grid 384

// @param speed 1.0 0.0 2.5 How fast the waves roll
// @param height 1.0 0.0 2.0 How tall the waves are
// @param choppy 0.85 0.0 1.0 How sharp the crests are: 0 rolls smoothly, 1 pinches them to points
// @param wind 0.0 -1.0 1.0 Turns the waves' heading left or right
// @param heat 0.5 0.0 1.0 Slides the sky from deep ember to white-hot

constant float TAU = 6.2831853;
// The eye, a little over the water, and the point it looks at.
constant float3 EYE = float3(0.0, 2.4, -2.0);
constant float3 AIM = float3(0.0, 1.3, 10.0);
// The kiln's glow: low in the sky, straight ahead.
constant float3 GLOW = float3(0.0, 0.07, 0.9975);
// How many waves add up to the sea.
constant int WAVES = 8;

// 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.
struct Vertex {
    float4 position [[position]];
    float3 world;
    float3 normal;
};

// Flowing Color Field's 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.5, near = 0.1, far = 120.0;
    float depth = (view.z - near) / (far - near) * view.z;
    return float4(view.x * zoom / aspect, view.y * zoom, depth, view.z);
}

// 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));
}

// Wave i: its heading (x, z, one unit long) and its length, from 14 units
// down to under one. The headings fan out around the wind's.
float3 waveOf(int i, constant Params& p) {
    float fan[8] = { 0.0, 0.5, -0.45, 0.85, -0.8, 0.3, -1.15, 1.3 };
    float a = fan[i] + p.wind * 0.9;
    return float3(sin(a), -cos(a), 14.0 * pow(0.66, float(i)));
}

// The sea at a point of the still surface: eight Gerstner waves added up.
// Each pinches its crests by an eighth of choppy, so all eight together
// never fold the surface. Short waves fade with the distance, where the
// grid's squares are too long to draw them. n gets the normal.
float3 sea(float2 xz, float t, constant Params& p, thread float3& n) {
    float3 at = float3(xz.x, 0.0, xz.y);
    float3 slope = float3(0.0, 1.0, 0.0);
    float far = length(xz - EYE.xz);
    for (int i = 0; i < WAVES; i++) {
        float3 w = waveOf(i, p);
        float2 d = w.xy;
        float k = TAU / w.z;
        float fade = 1.0 - smoothstep(w.z * 6.0, w.z * 14.0, far);
        float a = p.height * 0.12 / k * fade;
        float q = p.choppy / (k * max(a, 1e-4) * float(WAVES)) * fade;
        float omega = sqrt(9.8 * k) * 0.8;
        float phase = k * dot(d, xz) - omega * t + float(i) * 1.7;
        float s = sin(phase), c = cos(phase);
        at += float3(q * a * d.x * c, a * s, q * a * d.y * c);
        slope.x -= d.x * k * a * c;
        slope.z -= d.y * k * a * c;
        slope.y -= q * k * a * s;
    }
    n = normalize(slope);
    return at;
}

// Once for every corner: wherever the eight waves take it.
Vertex corner(float2 grid, constant Uniforms& u, constant Params& p) {
    float x = 6.0 * tan((grid.x - 0.5) * 2.7);
    float z = 70.0 * pow(grid.y, 1.9) - 1.0;
    float3 n;
    float3 world = sea(float2(x, z), u.time * p.speed, p, n);
    Vertex o;
    o.world = world;
    o.position = project(world, u.resolution.x / u.resolution.y);
    o.normal = n;
    return o;
}

// 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 sea under the surface: almost black, a deep wine colour.
float3 deepSea() {
    return float3(0.03, 0.012, 0.018);
}

// Calm sea far away in a direction: the sky in a flat mirror.
float3 farSea(float3 d, constant Params& p) {
    float3 r = float3(d.x, abs(d.y), d.z);
    return mix(deepSea(), kilnSky(r, p), mirror(abs(d.y)));
}

// Once for every pixel: the eye's ray bounced off the water into the sky,
// mirrored as much as the angle allows, a glint where a slope turns the glow
// to the eye, and calm far sea 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 col = mix(deepSea(), kilnSky(r, p), mirror(dot(-look, n)));
    col += kiln(0.92) * pow(max(dot(r, GLOW), 0.0), 600.0) * 1.2;
    float far = smoothstep(30.0, 64.0, length(v.world - EYE));
    return float4(mix(col, farSea(look, p), far), 1.0);
}

// Behind the sea: the sky above the horizon, and calm far sea 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.5;
    float3 d = normalize(axes[2] + axes[0] * s.x * aspect + axes[1] * s.y);
    return float4(d.y > 0.0 ? kilnSky(d, p) : farSea(d, p), 1.0);
}
7 lines from stage 3 that are gone
    float lit = max(dot(n, normalize(float3(0.0, 0.35, 1.0))), 0.0);
    float far = smoothstep(20.0, 60.0, length(v.world - EYE));
    float t = mix(0.06 + 0.9 * pow(lit, 1.5), 0.45, far);
    t += (p.heat - 0.5) * 0.3;
    return float4(kiln(t), 1.0);
⋯
    float3 low = kiln(0.45 + (p.heat - 0.5) * 0.3);
    return float4(d.y > 0.0 ? kilnSky(d, p) : low, 1.0);

Stage 5 · Ocean waves

Foam. As sea adds up the waves, it also adds up how each one squeezes the surface sideways: the Jacobian of the moved points, jxx jzz - jxz². It is 1 on still water and falls toward 0 where a crest is pinched, so corner turns it into a foam amount between 0.86 and 0.58. shade tears that into lace with three sizes of value noise that drift with the water: the more pinch, the lower the noise needs to reach to count as foam, so thin foam is threads and thick foam has only small holes. The glow also shines through the thin tops of the waves when you look toward it, read from each corner's crest (its height over the wave height). foam sets how much gathers, and heat slides the sky from deep ember to white-hot.

the kiln palette and shade(), from the finished shader
// Flowing Color Field's 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 sea: the sky mirrored as much as the angle
// allows, the glow shining through the thin tops of the waves, a glint, the
// foam on the pinched crests, and calm far sea 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 col = mix(deepSea(), kilnSky(r, p), mirror(dot(-look, n)));
    // Light through the crests: the glow behind a wave, seen through its top.
    float behind = pow(max(dot(look, GLOW), 0.0), 3.0);
    float through = behind * smoothstep(0.0, 1.0, v.crest);
    col += kiln(0.5 + p.heat * 0.15) * through * 0.35;
    col += kiln(0.92) * pow(max(dot(r, GLOW), 0.0), 600.0) * 1.2;
    // Foam: the pinch, torn into lace by three sizes of noise that drift
    // with the water. The more pinch, the lower the noise has to reach to
    // be foam, so thin foam is threads and thick foam has only small holes.
    float2 q = v.world.xz * 6.0 + float2(0.0, u.time * 0.6);
    float lace = vnoise(q) * 0.5 + vnoise(q * 2.3) * 0.3;
    lace += vnoise(q * 5.1) * 0.2;
    float thick = v.foam * p.foam * 0.75;
    float f = smoothstep(0.92 - thick, 1.08 - thick, lace);
    f *= min(thick * 5.0, 1.0);
    float3 froth = kiln(0.84 + p.heat * 0.06) * (0.35 + 0.3 * n.y) + col * 0.4;
    col = mix(col, froth, f * 0.85);
    float far = smoothstep(30.0, 64.0, length(v.world - EYE));
    return float4(mix(col, farSea(look, p), far), 1.0);
}
ocean-waves.metal, the whole file85 new lines since stage 4
// Ray Kiln · Ocean Waves
//
// @episode Ocean Waves
// @length 60
// @variation 30
// @variations 1,2,3,4,5,7,9,10
// @sweeps height, foam, heat
// @still 47.0
// @teaches Gerstner waves on a grid, foam on the crests, the sky in the water
// @category Vertex
// @tags vertex shaders, grids, displacement, gerstner waves, normals, jacobian, foam, fresnel, reflections, procedural sound
// @final Ocean waves | Eight Gerstner waves roll in from the kiln's glow, their crests pinch and gather foam, and the sea mirrors the sky.
//
// An open sea at dusk, under a sky lit like a kiln's mouth. The waves are Gerstner waves: each one moves the water in
// circles, so the crests sharpen and the troughs widen, as real swell does. Eight of them, long and short, roll in from
// the glow at angles of their own, and where they add up the crests pinch so tight the surface bunches together: foam
// gathers there. The host lays down a grid of 384 by 384 squares; `corner` moves every corner of it, and `shade`
// mirrors the sky in the water as much as its angle allows.
// 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 384

// @param speed 1.0 0.0 2.5 How fast the waves roll
// @param height 1.0 0.0 2.0 How tall the waves are
// @param choppy 0.85 0.0 1.0 How sharp the crests are: 0 rolls smoothly, 1 pinches them to points
// @param wind 0.0 -1.0 1.0 Turns the waves' heading left or right
// @param foam 0.8 0.0 1.0 How much foam gathers on the crests
// @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 waves add up to the sea.
constant int WAVES = 8;
// The eye, a little over the water, and the point it looks at.
constant float3 EYE = float3(0.0, 2.4, -2.0);
constant float3 AIM = float3(0.0, 1.3, 10.0);
// The kiln's glow: low in the sky, straight ahead.
constant float3 GLOW = float3(0.0, 0.07, 0.9975);

// What each corner carries to the pixels between: where it lands on screen,
// where it is in the world, which way the water faces, how much foam is on
// it, and how high it is.
struct Vertex {
    float4 position [[position]];
    float3 world;
    float3 normal;
    float foam;
    float crest;
};

// Flowing Color Field's 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));
}

// Flowing Color Field's hash: a 2D point in, a random 0 to 1 out.
float hash21(float2 p) {
    p = fract(p * float2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

// Value noise: random values on a grid, blended smoothly between the grid points.
float vnoise(float2 p) {
    float2 i = floor(p), f = fract(p);
    f = f * f * (3.0 - 2.0 * f);
    float a = hash21(i), b = hash21(i + float2(1, 0));
    float c = hash21(i + float2(0, 1)), d = hash21(i + float2(1, 1));
    return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

// 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.5, near = 0.1, far = 120.0;
    float depth = (view.z - near) / (far - near) * view.z;
    return float4(view.x * zoom / aspect, view.y * zoom, depth, view.z);
}

// Wave i: its heading (x, z, one unit long) and its length, from 14 units
// down to under one. The headings fan out around the wind's.
float3 waveOf(int i, constant Params& p) {
    float fan[8] = { 0.0, 0.5, -0.45, 0.85, -0.8, 0.3, -1.15, 1.3 };
    float a = fan[i] + p.wind * 0.9;
    return float3(sin(a), -cos(a), 14.0 * pow(0.66, float(i)));
}

// The sea at a point of the still surface, `t` seconds in. Each Gerstner wave
// lifts the point by A sin(phase) and pushes it along its heading by
// Q A cos(phase), so the water turns in circles. Each wave pinches its
// crests by Q k A, an eighth of `choppy`, so all eight add up to `choppy` at
// most, however tall: past 1 the surface would fold through itself. Short
// waves fade with the distance, where the grid is too coarse for them.
// Returns the moved point; `n` gets the normal and `squeeze` how much the
// surface has bunched up there (1 still, near 0 at a pinched crest).
float3 sea(float2 xz, float t, constant Params& p, thread float3& n,
           thread float& squeeze) {
    float3 at = float3(xz.x, 0.0, xz.y);
    float3 slope = float3(0.0, 1.0, 0.0);
    float jxx = 1.0, jzz = 1.0, jxz = 0.0;
    float far = length(xz - EYE.xz);
    for (int i = 0; i < WAVES; i++) {
        float3 w = waveOf(i, p);
        float2 d = w.xy;
        float k = TAU / w.z;
        float fade = 1.0 - smoothstep(w.z * 6.0, w.z * 14.0, far);
        float a = p.height * 0.12 / k * fade;
        float q = p.choppy / (k * max(a, 1e-4) * float(WAVES)) * fade;
        float omega = sqrt(9.8 * k) * 0.8;
        float phase = k * dot(d, xz) - omega * t + float(i) * 1.7;
        float s = sin(phase), c = cos(phase);
        at += float3(q * a * d.x * c, a * s, q * a * d.y * c);
        slope.x -= d.x * k * a * c;
        slope.z -= d.y * k * a * c;
        slope.y -= q * k * a * s;
        jxx -= q * k * a * d.x * d.x * s;
        jzz -= q * k * a * d.y * d.y * s;
        jxz -= q * k * a * d.x * d.y * s;
    }
    n = normalize(slope);
    squeeze = jxx * jzz - jxz * jxz;
    return at;
}

// Once for every corner: the grid's 0 to 1 becomes a sheet of sea in front
// of the eye, small squares near it and long ones far off, and every corner
// moves where the waves take it.
Vertex corner(float2 grid, constant Uniforms& u, constant Params& p) {
    float x = 6.0 * tan((grid.x - 0.5) * 2.7);
    float z = 70.0 * pow(grid.y, 1.9) - 1.0;
    float3 n;
    float squeeze;
    float3 world = sea(float2(x, z), u.time * p.speed, p, n, squeeze);
    Vertex o;
    o.world = world;
    o.position = project(world, u.resolution.x / u.resolution.y);
    o.normal = n;
    o.foam = smoothstep(0.86, 0.58, squeeze);
    o.crest = world.y / max(p.height, 0.05);
    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 sea under the surface: almost black, a deep wine colour.
float3 deepSea() {
    return float3(0.03, 0.012, 0.018);
}

// Calm sea far away in a direction: the sky in a flat mirror.
float3 farSea(float3 d, constant Params& p) {
    float3 r = float3(d.x, abs(d.y), d.z);
    return mix(deepSea(), kilnSky(r, p), mirror(abs(d.y)));
}

// Once for every pixel of the sea: the sky mirrored as much as the angle
// allows, the glow shining through the thin tops of the waves, a glint, the
// foam on the pinched crests, and calm far sea 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 col = mix(deepSea(), kilnSky(r, p), mirror(dot(-look, n)));
    // Light through the crests: the glow behind a wave, seen through its top.
    float behind = pow(max(dot(look, GLOW), 0.0), 3.0);
    float through = behind * smoothstep(0.0, 1.0, v.crest);
    col += kiln(0.5 + p.heat * 0.15) * through * 0.35;
    col += kiln(0.92) * pow(max(dot(r, GLOW), 0.0), 600.0) * 1.2;
    // Foam: the pinch, torn into lace by three sizes of noise that drift
    // with the water. The more pinch, the lower the noise has to reach to
    // be foam, so thin foam is threads and thick foam has only small holes.
    float2 q = v.world.xz * 6.0 + float2(0.0, u.time * 0.6);
    float lace = vnoise(q) * 0.5 + vnoise(q * 2.3) * 0.3;
    lace += vnoise(q * 5.1) * 0.2;
    float thick = v.foam * p.foam * 0.75;
    float f = smoothstep(0.92 - thick, 1.08 - thick, lace);
    f *= min(thick * 5.0, 1.0);
    float3 froth = kiln(0.84 + p.heat * 0.06) * (0.35 + 0.3 * n.y) + col * 0.4;
    col = mix(col, froth, f * 0.85);
    float far = smoothstep(30.0, 64.0, length(v.world - EYE));
    return float4(mix(col, farSea(look, p), far), 1.0);
}

// Behind the sea: the sky above the horizon, and calm far sea 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.5;
    float3 d = normalize(axes[2] + axes[0] * s.x * aspect + axes[1] * s.y);
    return float4(d.y > 0.0 ? kilnSky(d, p) : farSea(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.

// Cracked Clay'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);
}

// 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);
}

// The surf: the same sea, heard at three points ahead, left, middle and
// right. Where a crest pinches there the foam hisses, panned to its side;
// under it the swell rumbles, louder as the near water rises.
float2 sound(float t, constant Params& p) {
    float tt = t * p.speed;
    float2 v = float2(0.0);
    for (int i = 0; i < 3; i++) {
        float side = float(i - 1);
        float3 n;
        float squeeze;
        float3 at = sea(float2(side * 5.0, 7.0), tt, p, n, squeeze);
        float hiss = smoothstep(0.85, 0.55, squeeze) * p.foam;
        float wash = noise1(t, 5200.0, 11u + uint(i)) - 0.5;
        wash += 0.7 * (noise1(t, 2300.0, 31u + uint(i)) - 0.5);
        wash += 0.5 * (noise1(t, 900.0, 47u + uint(i)) - 0.5);
        float rise = smoothstep(-0.5, 1.0, at.y);
        float loud = (0.06 + 0.9 * hiss) * (0.6 + 0.4 * rise);
        float pan = 0.5 + side * 0.35;
        v += float2(1.0 - pan, pan) * wash * loud;
    }
    // The rumble: dark noise, swelling with the water just ahead.
    float3 n0;
    float s0;
    float3 near = sea(float2(0.0, 3.0), tt, p, n0, s0);
    float rumble = noise1(t, 140.0, 5u) - 0.5;
    rumble += 0.6 * (noise1(t, 60.0, 9u) - 0.5);
    float swell = smoothstep(-0.6 * p.height, 0.8 * p.height, near.y);
    float lift = 0.35 + 0.65 * swell;
    v += float2(1.0) * rumble * lift * 0.9 * min(p.height, 1.2);
    float fadeIn = smoothstep(0.0, 2.0, t);
    return tanh(v * 1.4) * 0.56 * p.volume * fadeIn;
}
10 lines from stage 4 that are gone
constant int WAVES = 8;
⋯
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));
}
⋯
float3 sea(float2 xz, float t, constant Params& p, thread float3& n) {
⋯
    float3 world = sea(float2(x, z), u.time * p.speed, p, n);

The sound

The surf is the same sea, heard at three points 7 units ahead, to the left, in the middle and to the right. sound calls sea at each of them, so it knows how pinched the surface is there: where a crest pinches, a hiss of bright noise (three layers, at 5200, 2300 and 900 random values a second) swells, panned to its side, a little louder when the water there is high. Under it is a rumble of dark noise that swells as the water 3 units ahead rises, so you hear the big swells pass. tanh rounds off the loudest moments, and it fades in over the first two seconds.

At the defaults the peak is about −8.1 dBFS and the average (RMS) about −19.8 dBFS over the minute; with height at 2, choppy and foam at 1 and volume at its highest, −5.5 dBFS and −16.2 dBFS.

New in Ray Kiln

Show a value in vertex episodes. Until now the playground's Show a value only worked in fragment shaders. Now it works in a grid episode too, in either function. Open Ocean Waves in the playground, put the cursor on a variable and press Show (or Alt-click it). In shade it works as in a fragment shader: each pixel shows the value as it last passed that line, mapped from the range under the picture, a number as grey and a float3 as its colour. In corner it works per corner: the value is kept where each corner passes the line and blended across each triangle, as the Vertex fields are, so you see what every corner of the grid worked out. The reading under the pointer gives the numbers on the surface nearest the eye there; over the sky it says the line never ran, because the background isn't part of the grid.

Try it on squeeze in corner: the still water is near 1 and the pinched crests drop toward 0, the same pattern the foam follows. Or on world.y to see the waves' height as grey, or on lace in shade to see the noise the foam is torn from. Mesh episodes such as Crystal Bloom don't have Show a value yet.

Try this

  • Set choppy to 0: the sea rolls smoothly and the foam is gone, because nothing pinches. At 1 every crest is sharp.
  • Set height to 2 for a storm, or 0.2 for a calm sea that mirrors the glow almost whole.
  • Turn wind to −1 or 1: the waves fan round the new heading and roll in at a slant.
  • Set heat to 1: the sky goes white-hot, and the water mirrors it gold.
  • In waveOf, change pow(0.66, float(i)) to pow(0.8, float(i)): the eight waves stay long, and the sea turns into big slow swell.
  • In corner, change smoothstep(0.86, 0.58, squeeze) to smoothstep(0.98, 0.7, squeeze): foam gathers on gentler crests too.

Parameters

ParameterDefaultRangeWhat it does
speed1.00.0 to 2.5How fast the waves roll
height1.00.0 to 2.0How tall the waves are
choppy0.850.0 to 1.0How sharp the crests are: 0 rolls smoothly, 1 pinches them to points
wind0.0-1.0 to 1.0Turns the waves' heading left or right
foam0.80.0 to 1.0How much foam gathers on the crests
heat0.50.0 to 1.0Slides the sky from deep ember to white-hot
volume0.80.0 to 1.0Loudness 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.

The full source

This is the whole episode: the picture, the parameters and the sound.

ocean-waves.metal
// Ray Kiln · Ocean Waves
//
// @episode Ocean Waves
// @length 60
// @variation 30
// @variations 1,2,3,4,5,7,9,10
// @sweeps height, foam, heat
// @still 47.0
// @teaches Gerstner waves on a grid, foam on the crests, the sky in the water
// @category Vertex
// @tags vertex shaders, grids, displacement, gerstner waves, normals, jacobian, foam, fresnel, reflections, procedural sound
// @final Ocean waves | Eight Gerstner waves roll in from the kiln's glow, their crests pinch and gather foam, and the sea mirrors the sky.
//
// An open sea at dusk, under a sky lit like a kiln's mouth. The waves are Gerstner waves: each one moves the water in
// circles, so the crests sharpen and the troughs widen, as real swell does. Eight of them, long and short, roll in from
// the glow at angles of their own, and where they add up the crests pinch so tight the surface bunches together: foam
// gathers there. The host lays down a grid of 384 by 384 squares; `corner` moves every corner of it, and `shade`
// mirrors the sky in the water as much as its angle allows.
// 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 384

// @param speed 1.0 0.0 2.5 How fast the waves roll
// @param height 1.0 0.0 2.0 How tall the waves are
// @param choppy 0.85 0.0 1.0 How sharp the crests are: 0 rolls smoothly, 1 pinches them to points
// @param wind 0.0 -1.0 1.0 Turns the waves' heading left or right
// @param foam 0.8 0.0 1.0 How much foam gathers on the crests
// @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 waves add up to the sea.
constant int WAVES = 8;
// The eye, a little over the water, and the point it looks at.
constant float3 EYE = float3(0.0, 2.4, -2.0);
constant float3 AIM = float3(0.0, 1.3, 10.0);
// The kiln's glow: low in the sky, straight ahead.
constant float3 GLOW = float3(0.0, 0.07, 0.9975);

// What each corner carries to the pixels between: where it lands on screen,
// where it is in the world, which way the water faces, how much foam is on
// it, and how high it is.
struct Vertex {
    float4 position [[position]];
    float3 world;
    float3 normal;
    float foam;
    float crest;
};

// Flowing Color Field's 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));
}

// Flowing Color Field's hash: a 2D point in, a random 0 to 1 out.
float hash21(float2 p) {
    p = fract(p * float2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

// Value noise: random values on a grid, blended smoothly between the grid points.
float vnoise(float2 p) {
    float2 i = floor(p), f = fract(p);
    f = f * f * (3.0 - 2.0 * f);
    float a = hash21(i), b = hash21(i + float2(1, 0));
    float c = hash21(i + float2(0, 1)), d = hash21(i + float2(1, 1));
    return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

// 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.5, near = 0.1, far = 120.0;
    float depth = (view.z - near) / (far - near) * view.z;
    return float4(view.x * zoom / aspect, view.y * zoom, depth, view.z);
}

// Wave i: its heading (x, z, one unit long) and its length, from 14 units
// down to under one. The headings fan out around the wind's.
float3 waveOf(int i, constant Params& p) {
    float fan[8] = { 0.0, 0.5, -0.45, 0.85, -0.8, 0.3, -1.15, 1.3 };
    float a = fan[i] + p.wind * 0.9;
    return float3(sin(a), -cos(a), 14.0 * pow(0.66, float(i)));
}

// The sea at a point of the still surface, `t` seconds in. Each Gerstner wave
// lifts the point by A sin(phase) and pushes it along its heading by
// Q A cos(phase), so the water turns in circles. Each wave pinches its
// crests by Q k A, an eighth of `choppy`, so all eight add up to `choppy` at
// most, however tall: past 1 the surface would fold through itself. Short
// waves fade with the distance, where the grid is too coarse for them.
// Returns the moved point; `n` gets the normal and `squeeze` how much the
// surface has bunched up there (1 still, near 0 at a pinched crest).
float3 sea(float2 xz, float t, constant Params& p, thread float3& n,
           thread float& squeeze) {
    float3 at = float3(xz.x, 0.0, xz.y);
    float3 slope = float3(0.0, 1.0, 0.0);
    float jxx = 1.0, jzz = 1.0, jxz = 0.0;
    float far = length(xz - EYE.xz);
    for (int i = 0; i < WAVES; i++) {
        float3 w = waveOf(i, p);
        float2 d = w.xy;
        float k = TAU / w.z;
        float fade = 1.0 - smoothstep(w.z * 6.0, w.z * 14.0, far);
        float a = p.height * 0.12 / k * fade;
        float q = p.choppy / (k * max(a, 1e-4) * float(WAVES)) * fade;
        float omega = sqrt(9.8 * k) * 0.8;
        float phase = k * dot(d, xz) - omega * t + float(i) * 1.7;
        float s = sin(phase), c = cos(phase);
        at += float3(q * a * d.x * c, a * s, q * a * d.y * c);
        slope.x -= d.x * k * a * c;
        slope.z -= d.y * k * a * c;
        slope.y -= q * k * a * s;
        jxx -= q * k * a * d.x * d.x * s;
        jzz -= q * k * a * d.y * d.y * s;
        jxz -= q * k * a * d.x * d.y * s;
    }
    n = normalize(slope);
    squeeze = jxx * jzz - jxz * jxz;
    return at;
}

// Once for every corner: the grid's 0 to 1 becomes a sheet of sea in front
// of the eye, small squares near it and long ones far off, and every corner
// moves where the waves take it.
Vertex corner(float2 grid, constant Uniforms& u, constant Params& p) {
    float x = 6.0 * tan((grid.x - 0.5) * 2.7);
    float z = 70.0 * pow(grid.y, 1.9) - 1.0;
    float3 n;
    float squeeze;
    float3 world = sea(float2(x, z), u.time * p.speed, p, n, squeeze);
    Vertex o;
    o.world = world;
    o.position = project(world, u.resolution.x / u.resolution.y);
    o.normal = n;
    o.foam = smoothstep(0.86, 0.58, squeeze);
    o.crest = world.y / max(p.height, 0.05);
    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 sea under the surface: almost black, a deep wine colour.
float3 deepSea() {
    return float3(0.03, 0.012, 0.018);
}

// Calm sea far away in a direction: the sky in a flat mirror.
float3 farSea(float3 d, constant Params& p) {
    float3 r = float3(d.x, abs(d.y), d.z);
    return mix(deepSea(), kilnSky(r, p), mirror(abs(d.y)));
}

// Once for every pixel of the sea: the sky mirrored as much as the angle
// allows, the glow shining through the thin tops of the waves, a glint, the
// foam on the pinched crests, and calm far sea 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 col = mix(deepSea(), kilnSky(r, p), mirror(dot(-look, n)));
    // Light through the crests: the glow behind a wave, seen through its top.
    float behind = pow(max(dot(look, GLOW), 0.0), 3.0);
    float through = behind * smoothstep(0.0, 1.0, v.crest);
    col += kiln(0.5 + p.heat * 0.15) * through * 0.35;
    col += kiln(0.92) * pow(max(dot(r, GLOW), 0.0), 600.0) * 1.2;
    // Foam: the pinch, torn into lace by three sizes of noise that drift
    // with the water. The more pinch, the lower the noise has to reach to
    // be foam, so thin foam is threads and thick foam has only small holes.
    float2 q = v.world.xz * 6.0 + float2(0.0, u.time * 0.6);
    float lace = vnoise(q) * 0.5 + vnoise(q * 2.3) * 0.3;
    lace += vnoise(q * 5.1) * 0.2;
    float thick = v.foam * p.foam * 0.75;
    float f = smoothstep(0.92 - thick, 1.08 - thick, lace);
    f *= min(thick * 5.0, 1.0);
    float3 froth = kiln(0.84 + p.heat * 0.06) * (0.35 + 0.3 * n.y) + col * 0.4;
    col = mix(col, froth, f * 0.85);
    float far = smoothstep(30.0, 64.0, length(v.world - EYE));
    return float4(mix(col, farSea(look, p), far), 1.0);
}

// Behind the sea: the sky above the horizon, and calm far sea 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.5;
    float3 d = normalize(axes[2] + axes[0] * s.x * aspect + axes[1] * s.y);
    return float4(d.y > 0.0 ? kilnSky(d, p) : farSea(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.

// Cracked Clay'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);
}

// 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);
}

// The surf: the same sea, heard at three points ahead, left, middle and
// right. Where a crest pinches there the foam hisses, panned to its side;
// under it the swell rumbles, louder as the near water rises.
float2 sound(float t, constant Params& p) {
    float tt = t * p.speed;
    float2 v = float2(0.0);
    for (int i = 0; i < 3; i++) {
        float side = float(i - 1);
        float3 n;
        float squeeze;
        float3 at = sea(float2(side * 5.0, 7.0), tt, p, n, squeeze);
        float hiss = smoothstep(0.85, 0.55, squeeze) * p.foam;
        float wash = noise1(t, 5200.0, 11u + uint(i)) - 0.5;
        wash += 0.7 * (noise1(t, 2300.0, 31u + uint(i)) - 0.5);
        wash += 0.5 * (noise1(t, 900.0, 47u + uint(i)) - 0.5);
        float rise = smoothstep(-0.5, 1.0, at.y);
        float loud = (0.06 + 0.9 * hiss) * (0.6 + 0.4 * rise);
        float pan = 0.5 + side * 0.35;
        v += float2(1.0 - pan, pan) * wash * loud;
    }
    // The rumble: dark noise, swelling with the water just ahead.
    float3 n0;
    float s0;
    float3 near = sea(float2(0.0, 3.0), tt, p, n0, s0);
    float rumble = noise1(t, 140.0, 5u) - 0.5;
    rumble += 0.6 * (noise1(t, 60.0, 9u) - 0.5);
    float swell = smoothstep(-0.6 * p.height, 0.8 * p.height, near.y);
    float lift = 0.35 + 0.65 * swell;
    v += float2(1.0) * rumble * lift * 0.9 * min(p.height, 1.2);
    float fadeIn = smoothstep(0.0, 2.0, t);
    return tanh(v * 1.4) * 0.56 * p.volume * fadeIn;
}

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.0, 0.85, 0.0, 0.8, 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(&params, length: params.count * MemoryLayout<Float>.stride, index: 1)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 3)

Time is in seconds, mouse is in pixels from the bottom left, and uv in the shader runs 0 to 1 with (0, 0) at the bottom left. Write the output as sRGB: the shader's numbers go to the screen as they are.

More in Geometry