Shader 17

Kiln Floor Tiles

The floor of a kiln laid with hexagonal clay tiles, each carrying three arcs of glowing glaze that join their neighbours' into lines winding across the floor. Now and then a tile lifts, turns a sixth of the way round and is set down with a clink, and the lines join up another way. The floor is a wallpaper: it holds whole tiles both ways, and the tiles, the heat flowing under them and the light all repeat with the picture, so copies laid side by side meet with no seam.

· Shapes & Symmetry · Fragment shader

Teaches Hex grids, Truchet tiles, wallpaper symmetry

Builds on 01 · Flowing Color Field (hash21), 03 · Floating 3D Scene (rotation), 05 · Cracked Clay (integer hash, sound noise)

Watch

The idea

A hexagonal grid looks hard and is two rectangular grids. Lay one grid of rectangles, lay a second shifted by half a rectangle each way, and give every point to whichever of the two nearest centres is closer: the boundaries between the closer-to-this and closer-to-that regions are hexagons.

Each hexagon then carries a Truchet tile: a pattern drawn so that its lines leave the tile only at the middles of its edges, and meet them square. Any tile can sit beside any other and the lines carry on, so a tile picked at random, turned one way or the other, makes long winding paths that nobody drew.

Last, the floor is made a wallpaper: a pattern that repeats exactly with the picture, so the right edge runs on into the left and the top into the bottom. That takes a whole number of tiles both ways, tile numbers that wrap round the edges, and noise and light that wrap too.

It is built in five steps, and each step is a shader you can run on its own.

Stage 1 · A hex grid

hexCell(p) finds a point's tile. The two grids have rectangles 1 wide and √3 high; a is the point measured from the centre of its rectangle in the first grid, b from the centre of its rectangle in the second, shifted by half of (1, √3). Whichever is shorter wins, and hexCell returns that offset (the point from its tile's centre, xy) and the centre itself (zw). The tiles are 1 wide across their flat sides, which are on the left and right.

hexDist is the distance from the centre in the hexagon's own measure: the largest of the distances along the three directions square to its edges, which by symmetry is the larger of |x| and dot(|p|, (½, √3⁄2)). It is 0.5 on every edge, so 0.5 - hexDist is how far a point is inside its tile, and the dark line is where that falls under 0.015. Each tile's colour comes from a hash of its centre, rounded to whole numbers first (twice the x, and the y over half of √3), so every pixel of a tile hashes the same number.

stage-1-hex-grid.metal
// Stage 1 · A hex grid
// Hexagons are two grids of rectangles, the second shifted by half a rectangle: each point goes to the nearer of
// its two centres. Each tile gets a colour from a hash of its centre, and a dark line where it reaches an edge.

// @param cells 9.0 3.0 16.0 How many tiles across the picture
// @param heat 0.5 0.0 1.0 Slides the tiles from deep ember to white-hot

constant float SQ3 = 1.7320508;

// The kiln palette from episode 1.
float3 kiln(float t) {
    float3 c0 = float3(0.020, 0.012, 0.020);
    float3 c1 = float3(0.280, 0.040, 0.030);
    float3 c2 = float3(0.880, 0.260, 0.050);
    float3 c3 = float3(1.000, 0.680, 0.200);
    float3 c4 = float3(1.000, 0.970, 0.840);
    t = clamp(t, 0.0, 1.0);
    float3 c = mix(c0, c1, smoothstep(0.00, 0.22, t));
    c = mix(c, c2, smoothstep(0.22, 0.50, t));
    c = mix(c, c3, smoothstep(0.50, 0.76, t));
    return mix(c, c4, smoothstep(0.76, 1.00, t));
}

// Episode 1's hash: a random number from a point.
float hash21(float2 p) {
    p = fract(p * float2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

// Two grids of rectangles, one shifted by half: a point belongs to the
// nearer centre. xy is the point from its centre, zw the centre itself.
float4 hexCell(float2 p) {
    float2 r = float2(1.0, SQ3), h = r * 0.5;
    float2 a = p - r * floor(p / r) - h;
    float2 b = (p - h) - r * floor((p - h) / r) - h;
    float2 gv = dot(a, a) < dot(b, b) ? a : b;
    return float4(gv, p - gv);
}

// Distance in the hexagon's own measure: 0.5 on its edges.
float hexDist(float2 gv) {
    gv = abs(gv);
    return max(gv.x, dot(gv, float2(0.5, SQ3 * 0.5)));
}

float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
    // `cells` tiles across, as many rows as fit.
    float n = floor(p.cells + 0.5);
    float2 sp = uv * float2(n, n * u.resolution.y / u.resolution.x);
    float4 cell = hexCell(sp);
    float aa = 1.5 * n / u.resolution.x;

    // The centre as whole numbers, so every pixel of a tile hashes the same.
    float2 id = floor(cell.zw * float2(2.0, 2.0 / SQ3) + 0.5);
    float3 col = kiln(0.15 + p.heat * 0.4 + 0.35 * hash21(id));
    float edge = 0.5 - hexDist(cell.xy);
    col *= smoothstep(0.015, 0.015 + aa, edge);
    return float4(col, 1.0);
}

Stage 2 · Truchet arcs

truchet(gv) draws three arcs. Each is centred on a corner of the hexagon, every other one (at 30°, 150° and 270°), with a radius of half an edge, so it runs from the middle of one edge beside its corner to the middle of the other. A circle centred on a corner crosses the edges through that corner square, so where an arc leaves one tile it carries straight on into the neighbour's arc. The function returns the distance to the nearest arc, and the channel is where that is under a width set by line, with a soft glow beyond.

A hexagon turned a sixth of the way round puts the arcs on its other three corners. Half the tiles, picked by a hash, are turned (rot(cell.xy, turn × TAU / 6)) before their arcs are measured, and the random mix of the two winds the arcs into paths and closed loops. Where three tiles all take a shared corner, its three arcs close into a ring.

stage-2-truchet.metal25 new lines since stage 1
// Stage 2 · Truchet arcs
// Each tile carries three arcs round every other corner, each joining the middles of two edges, square to them, so
// it runs on into the next tile's arc. Half the tiles, picked at random, are turned a sixth: the arcs wind into paths.

// @param cells 9.0 3.0 16.0 How many tiles across the picture
// @param line 0.5 0.0 1.0 How wide the glowing channels are
// @param heat 0.5 0.0 1.0 Slides the glow from deep ember to white-hot

constant float TAU = 6.2831853;
constant float SQ3 = 1.7320508;

// Episode 3's rotation.
float2 rot(float2 p, float a) {
    float c = cos(a), s = sin(a);
    return float2(c * p.x - s * p.y, s * p.x + c * p.y);
}

// The kiln palette from episode 1.
float3 kiln(float t) {
    float3 c0 = float3(0.020, 0.012, 0.020);
    float3 c1 = float3(0.280, 0.040, 0.030);
    float3 c2 = float3(0.880, 0.260, 0.050);
    float3 c3 = float3(1.000, 0.680, 0.200);
    float3 c4 = float3(1.000, 0.970, 0.840);
    t = clamp(t, 0.0, 1.0);
    float3 c = mix(c0, c1, smoothstep(0.00, 0.22, t));
    c = mix(c, c2, smoothstep(0.22, 0.50, t));
    c = mix(c, c3, smoothstep(0.50, 0.76, t));
    return mix(c, c4, smoothstep(0.76, 1.00, t));
}

// Episode 1's hash: a random number from a point.
float hash21(float2 p) {
    p = fract(p * float2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

// Two grids of rectangles, one shifted by half: a point belongs to the
// nearer centre. xy is the point from its centre, zw the centre itself.
float4 hexCell(float2 p) {
    float2 r = float2(1.0, SQ3), h = r * 0.5;
    float2 a = p - r * floor(p / r) - h;
    float2 b = (p - h) - r * floor((p - h) / r) - h;
    float2 gv = dot(a, a) < dot(b, b) ? a : b;
    return float4(gv, p - gv);
}

// Distance in the hexagon's own measure: 0.5 on its edges.
float hexDist(float2 gv) {
    gv = abs(gv);
    return max(gv.x, dot(gv, float2(0.5, SQ3 * 0.5)));
}

// Three arcs round every other corner of the tile. Each joins the middles
// of the two edges beside its corner, and meets them square, so it carries
// straight on into the neighbour's arc. Turned a sixth, the other three
// corners take them.
float truchet(float2 gv) {
    float R = 1.0 / SQ3;
    float d = 1e3;
    for (int k = 0; k < 3; k++) {
        float a = TAU / 12.0 + float(k) * TAU / 3.0;
        float2 corner = R * float2(cos(a), sin(a));
        d = min(d, abs(length(gv - corner) - R * 0.5));
    }
    return d;
}

float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
    float n = floor(p.cells + 0.5);
    float2 sp = uv * float2(n, n * u.resolution.y / u.resolution.x);
    float4 cell = hexCell(sp);
    float aa = 1.5 * n / u.resolution.x;

    // Half the tiles turned a sixth: their arcs go round the other corners.
    float2 id = floor(cell.zw * float2(2.0, 2.0 / SQ3) + 0.5);
    float turn = step(hash21(id + 3.1), 0.5);
    float2 gv = rot(cell.xy, turn * TAU / 6.0);
    float d = truchet(gv);

    float w = mix(0.02, 0.09, p.line);
    float channel = 1.0 - smoothstep(w - aa, w + aa, d);
    float3 hot = kiln(0.45 + p.heat * 0.45);
    float3 col = float3(0.05, 0.03, 0.025);
    col += hot * exp(-max(d - w, 0.0) * 22.0) * 0.3;
    col = mix(col, hot, channel);
    col *= smoothstep(0.012, 0.012 + aa, 0.5 - hexDist(gv));
    return float4(col, 1.0);
}
3 lines from stage 1 that are gone
    float3 col = kiln(0.15 + p.heat * 0.4 + 0.35 * hash21(id));
    float edge = 0.5 - hexDist(cell.xy);
    col *= smoothstep(0.015, 0.015 + aa, edge);

Stage 3 · A wallpaper

Until now the picture is cut wherever its edges fall: copies of it side by side would show a seam. Three things make it repeat.

  • Whole tiles. floorSize rounds cells to a whole number of tiles across, and works out how many pairs of rows fit down (rows pair up because every other row is shifted by half a tile). The floor is stretched to exactly that, so the hexagons are squashed or stretched a little, under 3% at the defaults, to fit.
  • Wrapped numbers. tileKey turns a tile's centre into whole numbers and wraps them, wrap(col, 2 × across) and wrap(row, 2 × pairs), so the tile half off the right edge is the same tile as the one half off the left and gets the same hash, the same turn and the same colour.
  • Noise that wraps. The heat under the floor is pfbm, value noise on a lattice whose corners are hashed after wrapping by the period, so it repeats every period cells; each octave doubles the lattice and the period together, so it still does. Sliding it along with the time keeps it repeating, and the channels glow down the kiln palette where it is cool and up where it is hot.
stage-3-wallpaper.metal43 new lines since stage 2
// Stage 3 · A wallpaper
// To repeat with the picture, the floor holds whole tiles both ways, squashed a little to fit, and tile numbers wrap
// round the edges. The heat under it is noise on a lattice that wraps. In the playground's tile view, no seams.

// @param speed 0.5 0.0 2.0 How fast the heat flows
// @param cells 9.0 3.0 16.0 How many tiles across the picture
// @param line 0.5 0.0 1.0 How wide the glowing channels are
// @param heat 0.5 0.0 1.0 Slides the glow from deep ember to white-hot

constant float TAU = 6.2831853;
constant float SQ3 = 1.7320508;

// Episode 3's rotation.
float2 rot(float2 p, float a) {
    float c = cos(a), s = sin(a);
    return float2(c * p.x - s * p.y, s * p.x + c * p.y);
}

// The kiln palette from episode 1.
float3 kiln(float t) {
    float3 c0 = float3(0.020, 0.012, 0.020);
    float3 c1 = float3(0.280, 0.040, 0.030);
    float3 c2 = float3(0.880, 0.260, 0.050);
    float3 c3 = float3(1.000, 0.680, 0.200);
    float3 c4 = float3(1.000, 0.970, 0.840);
    t = clamp(t, 0.0, 1.0);
    float3 c = mix(c0, c1, smoothstep(0.00, 0.22, t));
    c = mix(c, c2, smoothstep(0.22, 0.50, t));
    c = mix(c, c3, smoothstep(0.50, 0.76, t));
    return mix(c, c4, smoothstep(0.76, 1.00, t));
}

// Episode 1's hash: a random number from a point.
float hash21(float2 p) {
    p = fract(p * float2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

// Wrap x into 0 to m, below 0 too.
float wrap(float x, float m) {
    return x - m * floor(x / m);
}

// Whole tiles across, whole pairs of rows down: the picture holds whole
// tiles both ways.
float2 floorSize(float2 res, float cells) {
    float n = floor(cells + 0.5);
    float pairs = max(floor(n * res.y / (res.x * SQ3) + 0.5), 1.0);
    return float2(n, pairs);
}

// Two grids of rectangles, one shifted by half: a point belongs to the
// nearer centre. xy is the point from its centre, zw the centre itself.
float4 hexCell(float2 p) {
    float2 r = float2(1.0, SQ3), h = r * 0.5;
    float2 a = p - r * floor(p / r) - h;
    float2 b = (p - h) - r * floor((p - h) / r) - h;
    float2 gv = dot(a, a) < dot(b, b) ? a : b;
    return float4(gv, p - gv);
}

// A tile's number, wrapped: the tile leaving one edge is the one coming
// in at the other.
float2 tileKey(float2 centre, float2 size) {
    float col = floor(centre.x * 2.0 + 0.5);
    float row = floor(centre.y * 2.0 / SQ3 + 0.5);
    return float2(wrap(col, size.x * 2.0), wrap(row, size.y * 2.0));
}

// Distance in the hexagon's own measure: 0.5 on its edges.
float hexDist(float2 gv) {
    gv = abs(gv);
    return max(gv.x, dot(gv, float2(0.5, SQ3 * 0.5)));
}

// Three arcs round every other corner of the tile. Each joins the middles
// of the two edges beside its corner, and meets them square, so it carries
// straight on into the neighbour's arc. Turned a sixth, the other three
// corners take them.
float truchet(float2 gv) {
    float R = 1.0 / SQ3;
    float d = 1e3;
    for (int k = 0; k < 3; k++) {
        float a = TAU / 12.0 + float(k) * TAU / 3.0;
        float2 corner = R * float2(cos(a), sin(a));
        d = min(d, abs(length(gv - corner) - R * 0.5));
    }
    return d;
}

// Value noise on a lattice that wraps every `period` cells (whole numbers),
// so it repeats exactly. Moving it keeps it repeating.
float pnoise(float2 q, float2 period) {
    float2 i = floor(q), f = fract(q);
    f = f * f * (3.0 - 2.0 * f);
    float a = hash21(float2(wrap(i.x, period.x), wrap(i.y, period.y)));
    float b = hash21(float2(wrap(i.x + 1.0, period.x), wrap(i.y, period.y)));
    float c = hash21(float2(wrap(i.x, period.x), wrap(i.y + 1.0, period.y)));
    float d = hash21(float2(wrap(i.x + 1.0, period.x),
                            wrap(i.y + 1.0, period.y)));
    return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

// Three octaves, each lattice twice as fine and still whole: it repeats too.
float pfbm(float2 uv, float2 period, float2 drift) {
    float v = 0.0, a = 0.5;
    for (int i = 0; i < 3; i++) {
        v += a * pnoise(uv * period + drift, period);
        period *= 2.0;
        drift *= 2.0;
        a *= 0.5;
    }
    return v / 0.875;
}

float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
    float2 size = floorSize(u.resolution, p.cells);
    float2 sp = uv * float2(size.x, size.y * SQ3);
    float4 cell = hexCell(sp);
    float2 key = tileKey(cell.zw, size);
    float tt = u.time * p.speed;
    float aa = 1.5 * size.x / u.resolution.x;

    float turn = step(hash21(key + 3.1), 0.5);
    float2 gv = rot(cell.xy, turn * TAU / 6.0);
    float d = truchet(gv);

    // The heat under the floor, flowing, on a lattice of whole tiles.
    float2 period = float2(size.x, size.y * 2.0);
    float flow = pfbm(uv, period, float2(tt * 0.6, tt * 0.25));
    float glowT = 0.12 + p.heat * 0.5 + (flow - 0.35) * 0.8;

    float w = mix(0.02, 0.09, p.line);
    float channel = 1.0 - smoothstep(w - aa, w + aa, d);
    float3 hot = kiln(glowT);
    float3 col = float3(0.05, 0.03, 0.025);
    col += hot * exp(-max(d - w, 0.0) * 22.0) * 0.3;
    col = mix(col, hot, channel);
    col *= smoothstep(0.012, 0.012 + aa, 0.5 - hexDist(gv));
    return float4(col, 1.0);
}
6 lines from stage 2 that are gone
    float n = floor(p.cells + 0.5);
    float2 sp = uv * float2(n, n * u.resolution.y / u.resolution.x);
⋯
    float aa = 1.5 * n / u.resolution.x;
⋯
    float2 id = floor(cell.zw * float2(2.0, 2.0 / SQ3) + 0.5);
    float turn = step(hash21(id + 3.1), 0.5);
⋯
    float3 hot = kiln(0.45 + p.heat * 0.45);

Stage 4 · Turning tiles

tileTurn says how far round a tile has turned, in sixths. Each tile starts at 0 or 1 from a hash of its wrapped number (order lays more of them at 0, and at 1 every tile is laid the same way, which is the regular wallpaper of rings), and then turns once more each time its turn comes: every 6 / turns seconds of the clock scaled by speed, offset by another hash, easing round over 0.8 seconds with a smoothstep. Two turns are a third of a full circle, which puts the arcs back on the same corners, so each turn swaps the tile between its two ways and the paths join up anew.

While a tile turns it is lifted: its offset is scaled up by up to 10% (so the tile is drawn smaller) and the gap round it widens, so the dark grout opens round it as it goes round, and closes as it is set down. Because the turns come from the wrapped number too, the floor still repeats while it changes.

stage-4-turning.metal18 new lines since stage 3
// Stage 4 · Turning tiles
// Now and then a tile turns a sixth of the way round, lifting as it goes, and its arcs join their neighbours another
// way. Its turns come from a hash of its wrapped number, so the floor still repeats. Order lays more tiles alike.

// @param speed 0.5 0.0 2.0 How fast the heat flows and the tiles turn
// @param cells 9.0 3.0 16.0 How many tiles across the picture
// @param turns 0.5 0.0 1.0 How often a tile turns a sixth of the way round
// @param order 0.0 0.0 1.0 From tiles laid at random to every tile laid the same way
// @param line 0.5 0.0 1.0 How wide the glowing channels are
// @param heat 0.5 0.0 1.0 Slides the glow from deep ember to white-hot

constant float TAU = 6.2831853;
constant float SQ3 = 1.7320508;
// How long a turn takes.
constant float TURN = 0.8;

// Episode 3's rotation.
float2 rot(float2 p, float a) {
    float c = cos(a), s = sin(a);
    return float2(c * p.x - s * p.y, s * p.x + c * p.y);
}

// The kiln palette from episode 1.
float3 kiln(float t) {
    float3 c0 = float3(0.020, 0.012, 0.020);
    float3 c1 = float3(0.280, 0.040, 0.030);
    float3 c2 = float3(0.880, 0.260, 0.050);
    float3 c3 = float3(1.000, 0.680, 0.200);
    float3 c4 = float3(1.000, 0.970, 0.840);
    t = clamp(t, 0.0, 1.0);
    float3 c = mix(c0, c1, smoothstep(0.00, 0.22, t));
    c = mix(c, c2, smoothstep(0.22, 0.50, t));
    c = mix(c, c3, smoothstep(0.50, 0.76, t));
    return mix(c, c4, smoothstep(0.76, 1.00, t));
}

// Episode 1's hash: a random number from a point.
float hash21(float2 p) {
    p = fract(p * float2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

// Wrap x into 0 to m, below 0 too.
float wrap(float x, float m) {
    return x - m * floor(x / m);
}

// Whole tiles across, whole pairs of rows down: the picture holds whole
// tiles both ways.
float2 floorSize(float2 res, float cells) {
    float n = floor(cells + 0.5);
    float pairs = max(floor(n * res.y / (res.x * SQ3) + 0.5), 1.0);
    return float2(n, pairs);
}

// Two grids of rectangles, one shifted by half: a point belongs to the
// nearer centre. xy is the point from its centre, zw the centre itself.
float4 hexCell(float2 p) {
    float2 r = float2(1.0, SQ3), h = r * 0.5;
    float2 a = p - r * floor(p / r) - h;
    float2 b = (p - h) - r * floor((p - h) / r) - h;
    float2 gv = dot(a, a) < dot(b, b) ? a : b;
    return float4(gv, p - gv);
}

// A tile's number, wrapped: the tile leaving one edge is the one coming
// in at the other.
float2 tileKey(float2 centre, float2 size) {
    float col = floor(centre.x * 2.0 + 0.5);
    float row = floor(centre.y * 2.0 / SQ3 + 0.5);
    return float2(wrap(col, size.x * 2.0), wrap(row, size.y * 2.0));
}

// Distance in the hexagon's own measure: 0.5 on its edges.
float hexDist(float2 gv) {
    gv = abs(gv);
    return max(gv.x, dot(gv, float2(0.5, SQ3 * 0.5)));
}

// Three arcs round every other corner of the tile. Each joins the middles
// of the two edges beside its corner, and meets them square, so it carries
// straight on into the neighbour's arc. Turned a sixth, the other three
// corners take them.
float truchet(float2 gv) {
    float R = 1.0 / SQ3;
    float d = 1e3;
    for (int k = 0; k < 3; k++) {
        float a = TAU / 12.0 + float(k) * TAU / 3.0;
        float2 corner = R * float2(cos(a), sin(a));
        d = min(d, abs(length(gv - corner) - R * 0.5));
    }
    return d;
}

// How far a tile has turned, in sixths: 0 or 1 by its hash, then one
// more every `every` seconds, eased over TURN.
float tileTurn(float2 key, float tt, float order, float every) {
    float base = step(hash21(key + 3.1), 0.5 * (1.0 - order));
    if (every <= 0.0) return base;
    float offset = hash21(key + 7.7);
    float k = floor(tt / every - offset);
    float since = tt - (k + offset) * every;
    float e = smoothstep(0.0, TURN, since);
    return base + k + e;
}

// Seconds between a tile's turns; none for turns 0.
float turnEvery(float turns) {
    return turns > 0.01 ? 6.0 / turns : 0.0;
}

// Value noise on a lattice that wraps every `period` cells (whole numbers),
// so it repeats exactly. Moving it keeps it repeating.
float pnoise(float2 q, float2 period) {
    float2 i = floor(q), f = fract(q);
    f = f * f * (3.0 - 2.0 * f);
    float a = hash21(float2(wrap(i.x, period.x), wrap(i.y, period.y)));
    float b = hash21(float2(wrap(i.x + 1.0, period.x), wrap(i.y, period.y)));
    float c = hash21(float2(wrap(i.x, period.x), wrap(i.y + 1.0, period.y)));
    float d = hash21(float2(wrap(i.x + 1.0, period.x),
                            wrap(i.y + 1.0, period.y)));
    return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

// Three octaves, each lattice twice as fine and still whole: it repeats too.
float pfbm(float2 uv, float2 period, float2 drift) {
    float v = 0.0, a = 0.5;
    for (int i = 0; i < 3; i++) {
        v += a * pnoise(uv * period + drift, period);
        period *= 2.0;
        drift *= 2.0;
        a *= 0.5;
    }
    return v / 0.875;
}

float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
    float2 size = floorSize(u.resolution, p.cells);
    float2 sp = uv * float2(size.x, size.y * SQ3);
    float4 cell = hexCell(sp);
    float2 key = tileKey(cell.zw, size);
    float tt = u.time * p.speed;
    float aa = 1.5 * size.x / u.resolution.x;

    // Lifted a little while it turns, so the grout opens round it.
    float turn = tileTurn(key, tt, p.order, turnEvery(p.turns));
    float lift = sin(3.14159 * fract(turn)) * step(0.001, fract(turn));
    float2 gv = rot(cell.xy, turn * TAU / 6.0) * (1.0 + 0.1 * lift);
    float d = truchet(gv);

    float2 period = float2(size.x, size.y * 2.0);
    float flow = pfbm(uv, period, float2(tt * 0.6, tt * 0.25));
    float glowT = 0.12 + p.heat * 0.5 + (flow - 0.35) * 0.8;

    float w = mix(0.02, 0.09, p.line);
    float channel = 1.0 - smoothstep(w - aa, w + aa, d);
    float3 hot = kiln(glowT);
    float3 col = float3(0.05, 0.03, 0.025);
    col += hot * exp(-max(d - w, 0.0) * 22.0) * 0.3;
    col = mix(col, hot, channel);
    float gap = 0.012 + 0.03 * lift;
    col *= smoothstep(gap, gap + aa, 0.5 - hexDist(gv));
    return float4(col, 1.0);
}
3 lines from stage 3 that are gone
    float turn = step(hash21(key + 3.1), 0.5);
    float2 gv = rot(cell.xy, turn * TAU / 6.0);
⋯
    col *= smoothstep(0.012, 0.012 + aa, 0.5 - hexDist(gv));

Stage 5 · Kiln floor

The last step makes the tiles clay and lights them.

  • Clay. Each tile gets a brown of its own from a hash, and a bevel: within 0.07 of an edge, the clay is lit by how much the edge's outward direction faces the light, from the top left, so the tiles look raised.
  • Grout. Between the tiles the gap is dark, with the fire glowing deep in it: the kiln palette a little cooler than the channels.
  • Light. A light wanders over the floor. Its distance is measured round the picture's edges, as on a torus (dl -= floor(dl + 0.5)), so the light is a wallpaper too: as it drifts off one edge it comes in at the other.
  • Glaze. The channels are hottest down their middles and spill a little of their light onto the clay beside them, and the whole is eased by col / (1 + 0.35 col) so the hottest glaze doesn't clip.
the kiln palette and shade(), from the finished shader
// The kiln palette from episode 1.
float3 kiln(float t) {
    float3 c0 = float3(0.020, 0.012, 0.020);
    float3 c1 = float3(0.280, 0.040, 0.030);
    float3 c2 = float3(0.880, 0.260, 0.050);
    float3 c3 = float3(1.000, 0.680, 0.200);
    float3 c4 = float3(1.000, 0.970, 0.840);
    t = clamp(t, 0.0, 1.0);
    float3 c = mix(c0, c1, smoothstep(0.00, 0.22, t));
    c = mix(c, c2, smoothstep(0.22, 0.50, t));
    c = mix(c, c3, smoothstep(0.50, 0.76, t));
    return mix(c, c4, smoothstep(0.76, 1.00, t));
}

float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
    float2 size = floorSize(u.resolution, p.cells);
    float2 sp = uv * float2(size.x, size.y * SQ3);
    float4 cell = hexCell(sp);
    float2 key = tileKey(cell.zw, size);
    float tt = u.time * p.speed;

    // Lifted a little while it turns, so the grout opens round it.
    float turn = tileTurn(key, tt, p.order, turnEvery(p.turns));
    float lift = sin(3.14159 * fract(turn)) * step(0.001, fract(turn));
    float2 gv = rot(cell.xy, turn * TAU / 6.0) * (1.0 + 0.1 * lift);

    float aa = 1.5 * size.x / u.resolution.x;
    float edge = 0.5 - hexDist(gv);
    float d = truchet(gv);

    // The heat under the floor, flowing, on a lattice of whole tiles.
    float2 period = float2(size.x, size.y * 2.0);
    float flow = pfbm(uv, period, float2(tt * 0.6, tt * 0.25));
    float glowT = 0.12 + p.heat * 0.5 + (flow - 0.35) * 0.8;

    // The clay: a shade of its own for each tile, a bevel lit from the top
    // left, and grout between the tiles where the fire shows through.
    float shadeOf = hash21(key + 11.3);
    float3 clay = mix(float3(0.20, 0.11, 0.08), float3(0.36, 0.22, 0.15),
                      shadeOf);
    float2 nrm = abs(gv.x) > dot(abs(gv), float2(0.5, SQ3 * 0.5))
        ? float2(sign(gv.x), 0.0) : sign(gv) * float2(0.5, SQ3 * 0.5);
    float bevel = 1.0 - smoothstep(0.0, 0.07, edge);
    clay *= 1.0 + bevel * 0.9 * dot(nrm, normalize(float2(-0.6, 0.8)));

    // A light wandering over the floor. Its distance is measured round the
    // picture's edges, as on a torus, so it repeats with the floor too.
    float2 at = float2(0.5 + 0.3 * sin(tt * 0.21), 0.5 + 0.3 * cos(tt * 0.17));
    float2 dl = uv - at;
    dl -= floor(dl + 0.5);
    dl.x *= u.resolution.x / u.resolution.y;
    float light = 0.45 + 0.9 * exp(-dot(dl, dl) * 5.0);
    float3 col = clay * light;

    // The channels: glaze glowing with the heat, spilling light on the clay.
    // Hottest down the middle of the channel.
    float w = mix(0.02, 0.09, p.line);
    float channel = 1.0 - smoothstep(w - aa, w + aa, d);
    float3 hot = kiln(glowT);
    col += hot * exp(-max(d - w, 0.0) * 22.0) * 0.3;
    float core = 1.0 - d / max(w, 1e-3);
    col = mix(col, hot * (0.8 + 0.9 * core), channel);

    // Grout: dark, with the fire glowing deep in it.
    float gap = 0.012 + 0.03 * lift;
    float grout = 1.0 - smoothstep(gap, gap + aa, edge);
    float3 deep = kiln(glowT * 0.75) * 0.5;
    col = mix(col, deep, grout);

    col = col / (1.0 + col * 0.35);
    return float4(col, 1.0);
}
episode-17-kiln-floor-tiles.metal, the whole file71 new lines since stage 4
// Ray Kiln · Episode 17 · Kiln Floor Tiles
//
// @episode 17 Kiln Floor Tiles
// @length 60
// @variation 30
// @variations 2,3,4,6,7,8,9,12
// @sweeps cells, line, heat
// @still 20.0
// @teaches Hex grids, Truchet tiles, wallpaper symmetry
// @category Fragment
// @tags hex grid, truchet tiles, wallpaper symmetry, periodic noise, procedural sound
// @final Kiln floor | Clay tiles with bevelled edges, fire showing through the grout, the glowing channels lit by a light that wanders and wraps round the picture.
//
// The floor of a kiln laid with hexagonal tiles. Each tile carries three arcs of glowing glaze, and since every arc
// ends at the middle of an edge, the arcs join their neighbours' into lines that wind across the whole floor: Truchet
// tiles. Now and then a tile turns a sixth of the way round and the lines join up another way. The floor is a
// wallpaper: it fits a whole number of tiles in the picture, and everything on it, the tiles, the heat flowing along
// the channels and the light, repeats with the picture, so copies laid side by side meet with no seam.
// Each @param line below becomes a slider in the host app and a row in the page's parameters table:
//   // @param name default min max description

// @param speed 0.5 0.0 2.0 How fast the heat flows and the tiles turn
// @param cells 9.0 3.0 16.0 How many tiles across the picture
// @param turns 0.5 0.0 1.0 How often a tile turns a sixth of the way round
// @param order 0.0 0.0 1.0 From tiles laid at random to every tile laid the same way
// @param line 0.5 0.0 1.0 How wide the glowing channels are
// @param heat 0.5 0.0 1.0 Slides the glow from deep ember to white-hot
// @param volume 0.8 0.0 1.0 Loudness of the sound

constant float TAU = 6.2831853;
constant float SQ3 = 1.7320508;
// How long a turn takes.
constant float TURN = 0.8;

// Episode 3's rotation.
float2 rot(float2 p, float a) {
    float c = cos(a), s = sin(a);
    return float2(c * p.x - s * p.y, s * p.x + c * p.y);
}

// The kiln palette from episode 1.
float3 kiln(float t) {
    float3 c0 = float3(0.020, 0.012, 0.020);
    float3 c1 = float3(0.280, 0.040, 0.030);
    float3 c2 = float3(0.880, 0.260, 0.050);
    float3 c3 = float3(1.000, 0.680, 0.200);
    float3 c4 = float3(1.000, 0.970, 0.840);
    t = clamp(t, 0.0, 1.0);
    float3 c = mix(c0, c1, smoothstep(0.00, 0.22, t));
    c = mix(c, c2, smoothstep(0.22, 0.50, t));
    c = mix(c, c3, smoothstep(0.50, 0.76, t));
    return mix(c, c4, smoothstep(0.76, 1.00, t));
}

// Episode 1's hash: a random number from a point.
float hash21(float2 p) {
    p = fract(p * float2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

// Wrap x into 0 to m, below 0 too.
float wrap(float x, float m) {
    return x - m * floor(x / m);
}

// ---- The hex grid ----------------------------------------------------------------------------------------------

// Whole tiles across, whole pairs of rows down: the picture holds whole
// tiles both ways.
float2 floorSize(float2 res, float cells) {
    float n = floor(cells + 0.5);
    float pairs = max(floor(n * res.y / (res.x * SQ3) + 0.5), 1.0);
    return float2(n, pairs);
}

// Two grids of rectangles, one shifted by half: a point belongs to the
// nearer centre. xy is the point from its centre, zw the centre itself.
float4 hexCell(float2 p) {
    float2 r = float2(1.0, SQ3), h = r * 0.5;
    float2 a = p - r * floor(p / r) - h;
    float2 b = (p - h) - r * floor((p - h) / r) - h;
    float2 gv = dot(a, a) < dot(b, b) ? a : b;
    return float4(gv, p - gv);
}

// A tile's number, wrapped: the tile leaving one edge is the one coming
// in at the other.
float2 tileKey(float2 centre, float2 size) {
    float col = floor(centre.x * 2.0 + 0.5);
    float row = floor(centre.y * 2.0 / SQ3 + 0.5);
    return float2(wrap(col, size.x * 2.0), wrap(row, size.y * 2.0));
}

// Distance in the hexagon's own measure: 0.5 on its edges.
float hexDist(float2 gv) {
    gv = abs(gv);
    return max(gv.x, dot(gv, float2(0.5, SQ3 * 0.5)));
}

// ---- Truchet arcs ----------------------------------------------------------------------------------------------

// Three arcs round every other corner of the tile. Each joins the middles
// of the two edges beside its corner, and meets them square, so it carries
// straight on into the neighbour's arc. Turned a sixth, the other three
// corners take them.
float truchet(float2 gv) {
    float R = 1.0 / SQ3;
    float d = 1e3;
    for (int k = 0; k < 3; k++) {
        float a = TAU / 12.0 + float(k) * TAU / 3.0;
        float2 corner = R * float2(cos(a), sin(a));
        d = min(d, abs(length(gv - corner) - R * 0.5));
    }
    return d;
}

// How far a tile has turned, in sixths: 0 or 1 by its hash, then one
// more every `every` seconds, eased over TURN.
float tileTurn(float2 key, float tt, float order, float every) {
    float base = step(hash21(key + 3.1), 0.5 * (1.0 - order));
    if (every <= 0.0) return base;
    float offset = hash21(key + 7.7);
    float k = floor(tt / every - offset);
    float since = tt - (k + offset) * every;
    float e = smoothstep(0.0, TURN, since);
    return base + k + e;
}

// Seconds between a tile's turns; none for turns 0.
float turnEvery(float turns) {
    return turns > 0.01 ? 6.0 / turns : 0.0;
}

// ---- Heat that repeats with the floor --------------------------------------------------------------------------

// Value noise on a lattice that wraps every `period` cells (whole numbers),
// so it repeats exactly. Moving it keeps it repeating.
float pnoise(float2 q, float2 period) {
    float2 i = floor(q), f = fract(q);
    f = f * f * (3.0 - 2.0 * f);
    float a = hash21(float2(wrap(i.x, period.x), wrap(i.y, period.y)));
    float b = hash21(float2(wrap(i.x + 1.0, period.x), wrap(i.y, period.y)));
    float c = hash21(float2(wrap(i.x, period.x), wrap(i.y + 1.0, period.y)));
    float d = hash21(float2(wrap(i.x + 1.0, period.x),
                            wrap(i.y + 1.0, period.y)));
    return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

// Three octaves, each lattice twice as fine and still whole: it repeats too.
float pfbm(float2 uv, float2 period, float2 drift) {
    float v = 0.0, a = 0.5;
    for (int i = 0; i < 3; i++) {
        v += a * pnoise(uv * period + drift, period);
        period *= 2.0;
        drift *= 2.0;
        a *= 0.5;
    }
    return v / 0.875;
}

// ---- The floor -------------------------------------------------------------------------------------------------

float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
    float2 size = floorSize(u.resolution, p.cells);
    float2 sp = uv * float2(size.x, size.y * SQ3);
    float4 cell = hexCell(sp);
    float2 key = tileKey(cell.zw, size);
    float tt = u.time * p.speed;

    // Lifted a little while it turns, so the grout opens round it.
    float turn = tileTurn(key, tt, p.order, turnEvery(p.turns));
    float lift = sin(3.14159 * fract(turn)) * step(0.001, fract(turn));
    float2 gv = rot(cell.xy, turn * TAU / 6.0) * (1.0 + 0.1 * lift);

    float aa = 1.5 * size.x / u.resolution.x;
    float edge = 0.5 - hexDist(gv);
    float d = truchet(gv);

    // The heat under the floor, flowing, on a lattice of whole tiles.
    float2 period = float2(size.x, size.y * 2.0);
    float flow = pfbm(uv, period, float2(tt * 0.6, tt * 0.25));
    float glowT = 0.12 + p.heat * 0.5 + (flow - 0.35) * 0.8;

    // The clay: a shade of its own for each tile, a bevel lit from the top
    // left, and grout between the tiles where the fire shows through.
    float shadeOf = hash21(key + 11.3);
    float3 clay = mix(float3(0.20, 0.11, 0.08), float3(0.36, 0.22, 0.15),
                      shadeOf);
    float2 nrm = abs(gv.x) > dot(abs(gv), float2(0.5, SQ3 * 0.5))
        ? float2(sign(gv.x), 0.0) : sign(gv) * float2(0.5, SQ3 * 0.5);
    float bevel = 1.0 - smoothstep(0.0, 0.07, edge);
    clay *= 1.0 + bevel * 0.9 * dot(nrm, normalize(float2(-0.6, 0.8)));

    // A light wandering over the floor. Its distance is measured round the
    // picture's edges, as on a torus, so it repeats with the floor too.
    float2 at = float2(0.5 + 0.3 * sin(tt * 0.21), 0.5 + 0.3 * cos(tt * 0.17));
    float2 dl = uv - at;
    dl -= floor(dl + 0.5);
    dl.x *= u.resolution.x / u.resolution.y;
    float light = 0.45 + 0.9 * exp(-dot(dl, dl) * 5.0);
    float3 col = clay * light;

    // The channels: glaze glowing with the heat, spilling light on the clay.
    // Hottest down the middle of the channel.
    float w = mix(0.02, 0.09, p.line);
    float channel = 1.0 - smoothstep(w - aa, w + aa, d);
    float3 hot = kiln(glowT);
    col += hot * exp(-max(d - w, 0.0) * 22.0) * 0.3;
    float core = 1.0 - d / max(w, 1e-3);
    col = mix(col, hot * (0.8 + 0.9 * core), channel);

    // Grout: dark, with the fire glowing deep in it.
    float gap = 0.012 + 0.03 * lift;
    float grout = 1.0 - smoothstep(gap, gap + aa, edge);
    float3 deep = kiln(glowT * 0.75) * 0.5;
    col = mix(col, deep, grout);

    col = col / (1.0 + col * 0.35);
    return float4(col, 1.0);
}

// ---- Sound -----------------------------------------------------------------------------------------------------

// Episode 5's sound hash: a random number from a whole number.
float hashU(uint n) {
    n = (n << 13u) ^ n;
    n = n * (n * n * 15731u + 789221u) + 1376312589u;
    return float(n & 0x7fffffffu) / float(0x7fffffff);
}

// Episode 5's sound noise: random values joined `rate` times a second.
float noise1(float t, float rate, uint seed) {
    float x = t * rate;
    float i = floor(x), f = fract(x);
    f = f * f * (3.0 - 2.0 * f);
    uint k = uint(i) + seed * 7919u;
    return mix(hashU(k), hashU(k + 1u), f) * 2.0 - 1.0;
}

float2 sound(float t, constant Params& p) {
    // The floor as the 16:9 picture lays it: the same tiles, the same turns.
    float2 size = floorSize(float2(16.0, 9.0), p.cells);
    float tt = t * p.speed;
    float every = turnEvery(p.turns);
    float s = max(p.speed, 0.01);

    // Each tile set down after its turn clinks: a ceramic ring, three
    // partials not in tune with each other, its pitch from the tile's number
    // on a pentatonic scale, from where it lies on the floor.
    float2 clinks = float2(0.0);
    if (every > 0.0 && p.speed > 0.001) {
        for (float row = 0.0; row < size.y * 2.0; row += 1.0) {
            for (float col = wrap(row, 2.0); col < size.x * 2.0; col += 2.0) {
                float2 key = float2(col, row);
                float offset = hash21(key + 7.7);
                float k = floor(tt / every - offset);
                float age = (tt - (k + offset) * every - TURN) / s;
                if (k < 0.0 || age < 0.0 || age > 1.2) continue;
                float note = floor(hash21(key + 11.3) * 10.0);
                float semis = floor(note / 5.0) * 12.0
                    + float(int(note) % 5) * 2.4;
                float f = 660.0 * exp2(floor(semis + 0.5) / 12.0);
                float ring = sin(TAU * f * age) * exp(-age * 9.0)
                           + 0.5 * sin(TAU * f * 2.76 * age) * exp(-age * 16.0)
                           + 0.25 * sin(TAU * f * 5.4 * age) * exp(-age * 28.0);
                ring *= smoothstep(0.0, 0.002, age);
                float pan = col / (size.x * 2.0);
                clinks += ring * float2(1.0 - pan, pan);
            }
        }
    }
    clinks *= 0.26;

    // The kiln under the floor: a low drone, a fifth above it, swelling with
    // the heat, and a roar of slow noise.
    float swell = 0.6 + 0.4 * sin(tt * 0.6);
    float drone = sin(TAU * 73.4 * t) + 0.5 * sin(TAU * 110.0 * t + 0.4);
    drone *= 0.24 * (0.4 + 0.6 * p.heat) * swell;
    float roar = noise1(t, 140.0, 1u) * 0.6 + noise1(t, 420.0, 2u) * 0.4;
    roar *= 0.19 * (0.5 + 0.5 * p.heat);

    // The glaze fizzing in its channels: a little bright hiss, more for
    // wider and hotter channels.
    float fizz = noise1(t, 5200.0, 3u) * 0.03 * p.line * (0.3 + p.heat);

    float2 mixdown = float2(drone + roar) + float2(fizz, -fizz) + clinks;
    float fadeIn = smoothstep(0.0, 1.5, t);
    return tanh(mixdown * 1.6) * 0.66 * fadeIn * p.volume;
}
4 lines from stage 4 that are gone
    float aa = 1.5 * size.x / u.resolution.x;
⋯
    float3 col = float3(0.05, 0.03, 0.025);
⋯
    col = mix(col, hot, channel);
⋯
    col *= smoothstep(gap, gap + aa, 0.5 - hexDist(gv));

The sound

The sound is worked out from the time alone, and reads the same sliders as the picture.

  • The tiles. Every tile that turns in the picture clinks in the sound as it is set down. sound lays out the same floor (for a 16:9 picture, as the videos are) and goes through every tile, working out its last turn with the same hashes tileTurn uses; one set down in the last 1.2 seconds rings. The ring is three partials not in tune with each other, at 1, 2.76 and 5.4 times its pitch, each dying away faster than the one below, as fired clay rings. The pitch comes from the tile's hash, on a pentatonic scale over two octaves from 660 Hz, and it pans by where the tile lies on the floor. With turns at 0 the floor is silent but for the kiln.
  • The kiln. A low drone at 73.4 Hz with a fifth above it, swelling slowly and louder with heat, and a roar of slow noise.
  • The glaze. A faint bright fizz, more for wider and hotter channels.

Everything goes through tanh and fades in over the first second and a half. At the defaults the peak is about -7.5 dBFS and the average (RMS) about -21 dBFS.

New in Ray Kiln

The tile view. In the playground, press Tile under the picture (or T, outside the code) and the picture is drawn three times across and three times down, each copy a whole picture at a third of the size. Where the copies meet, a pattern that really repeats joins up, and one that doesn't shows a seam. It works for any fragment shader without feedback, live only: Show a value reads the copy under the pointer, the pointer moves in that copy, and the guides are drawn in each. It is free.

This episode is made to be looked at that way. Open stage 2 and press Tile: the paths break where the copies meet, because the floor is cut wherever the picture ends and the tiles on either side of a join were picked apart. Open stage 3 or the finished shader and the nine copies are one floor, the paths winding from one into the next, the heat flowing across the joins, and in the finished shader the light drifting out of one copy and into its neighbour.

Try this

  • Set order to 1 and turns to 0: every tile laid the same way, and the floor becomes a regular wallpaper of rings.
  • Set cells to 3 and line to 1: three big tiles across, fat channels of glaze.
  • Set turns to 1 and speed to 2: the floor keeps rearranging, and the clinks become a rain of notes.
  • In truchet, change R * 0.5 to R * 0.4: the arcs no longer meet the edges at their middles, and the paths break at most joins.
  • In shade, change float2(tt * 0.6, tt * 0.25) to float2(tt * 0.6, 0.0): the heat flows straight across the floor, and still wraps.
  • In floorSize, take the floor( and its + 0.5) away from pairs, leaving max(n * res.y / (res.x * SQ3), 1.0): the rows no longer fit the picture, and the tile view shows the seam come back.

Parameters

ParameterDefaultRangeWhat it does
speed0.50.0 to 2.0How fast the heat flows and the tiles turn
cells9.03.0 to 16.0How many tiles across the picture
turns0.50.0 to 1.0How often a tile turns a sixth of the way round
order0.00.0 to 1.0From tiles laid at random to every tile laid the same way
line0.50.0 to 1.0How wide the glowing channels are
heat0.50.0 to 1.0Slides the glow 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.

episode-17-kiln-floor-tiles.metal
// Ray Kiln · Episode 17 · Kiln Floor Tiles
//
// @episode 17 Kiln Floor Tiles
// @length 60
// @variation 30
// @variations 2,3,4,6,7,8,9,12
// @sweeps cells, line, heat
// @still 20.0
// @teaches Hex grids, Truchet tiles, wallpaper symmetry
// @category Fragment
// @tags hex grid, truchet tiles, wallpaper symmetry, periodic noise, procedural sound
// @final Kiln floor | Clay tiles with bevelled edges, fire showing through the grout, the glowing channels lit by a light that wanders and wraps round the picture.
//
// The floor of a kiln laid with hexagonal tiles. Each tile carries three arcs of glowing glaze, and since every arc
// ends at the middle of an edge, the arcs join their neighbours' into lines that wind across the whole floor: Truchet
// tiles. Now and then a tile turns a sixth of the way round and the lines join up another way. The floor is a
// wallpaper: it fits a whole number of tiles in the picture, and everything on it, the tiles, the heat flowing along
// the channels and the light, repeats with the picture, so copies laid side by side meet with no seam.
// Each @param line below becomes a slider in the host app and a row in the page's parameters table:
//   // @param name default min max description

// @param speed 0.5 0.0 2.0 How fast the heat flows and the tiles turn
// @param cells 9.0 3.0 16.0 How many tiles across the picture
// @param turns 0.5 0.0 1.0 How often a tile turns a sixth of the way round
// @param order 0.0 0.0 1.0 From tiles laid at random to every tile laid the same way
// @param line 0.5 0.0 1.0 How wide the glowing channels are
// @param heat 0.5 0.0 1.0 Slides the glow from deep ember to white-hot
// @param volume 0.8 0.0 1.0 Loudness of the sound

constant float TAU = 6.2831853;
constant float SQ3 = 1.7320508;
// How long a turn takes.
constant float TURN = 0.8;

// Episode 3's rotation.
float2 rot(float2 p, float a) {
    float c = cos(a), s = sin(a);
    return float2(c * p.x - s * p.y, s * p.x + c * p.y);
}

// The kiln palette from episode 1.
float3 kiln(float t) {
    float3 c0 = float3(0.020, 0.012, 0.020);
    float3 c1 = float3(0.280, 0.040, 0.030);
    float3 c2 = float3(0.880, 0.260, 0.050);
    float3 c3 = float3(1.000, 0.680, 0.200);
    float3 c4 = float3(1.000, 0.970, 0.840);
    t = clamp(t, 0.0, 1.0);
    float3 c = mix(c0, c1, smoothstep(0.00, 0.22, t));
    c = mix(c, c2, smoothstep(0.22, 0.50, t));
    c = mix(c, c3, smoothstep(0.50, 0.76, t));
    return mix(c, c4, smoothstep(0.76, 1.00, t));
}

// Episode 1's hash: a random number from a point.
float hash21(float2 p) {
    p = fract(p * float2(123.34, 456.21));
    p += dot(p, p + 45.32);
    return fract(p.x * p.y);
}

// Wrap x into 0 to m, below 0 too.
float wrap(float x, float m) {
    return x - m * floor(x / m);
}

// ---- The hex grid ----------------------------------------------------------------------------------------------

// Whole tiles across, whole pairs of rows down: the picture holds whole
// tiles both ways.
float2 floorSize(float2 res, float cells) {
    float n = floor(cells + 0.5);
    float pairs = max(floor(n * res.y / (res.x * SQ3) + 0.5), 1.0);
    return float2(n, pairs);
}

// Two grids of rectangles, one shifted by half: a point belongs to the
// nearer centre. xy is the point from its centre, zw the centre itself.
float4 hexCell(float2 p) {
    float2 r = float2(1.0, SQ3), h = r * 0.5;
    float2 a = p - r * floor(p / r) - h;
    float2 b = (p - h) - r * floor((p - h) / r) - h;
    float2 gv = dot(a, a) < dot(b, b) ? a : b;
    return float4(gv, p - gv);
}

// A tile's number, wrapped: the tile leaving one edge is the one coming
// in at the other.
float2 tileKey(float2 centre, float2 size) {
    float col = floor(centre.x * 2.0 + 0.5);
    float row = floor(centre.y * 2.0 / SQ3 + 0.5);
    return float2(wrap(col, size.x * 2.0), wrap(row, size.y * 2.0));
}

// Distance in the hexagon's own measure: 0.5 on its edges.
float hexDist(float2 gv) {
    gv = abs(gv);
    return max(gv.x, dot(gv, float2(0.5, SQ3 * 0.5)));
}

// ---- Truchet arcs ----------------------------------------------------------------------------------------------

// Three arcs round every other corner of the tile. Each joins the middles
// of the two edges beside its corner, and meets them square, so it carries
// straight on into the neighbour's arc. Turned a sixth, the other three
// corners take them.
float truchet(float2 gv) {
    float R = 1.0 / SQ3;
    float d = 1e3;
    for (int k = 0; k < 3; k++) {
        float a = TAU / 12.0 + float(k) * TAU / 3.0;
        float2 corner = R * float2(cos(a), sin(a));
        d = min(d, abs(length(gv - corner) - R * 0.5));
    }
    return d;
}

// How far a tile has turned, in sixths: 0 or 1 by its hash, then one
// more every `every` seconds, eased over TURN.
float tileTurn(float2 key, float tt, float order, float every) {
    float base = step(hash21(key + 3.1), 0.5 * (1.0 - order));
    if (every <= 0.0) return base;
    float offset = hash21(key + 7.7);
    float k = floor(tt / every - offset);
    float since = tt - (k + offset) * every;
    float e = smoothstep(0.0, TURN, since);
    return base + k + e;
}

// Seconds between a tile's turns; none for turns 0.
float turnEvery(float turns) {
    return turns > 0.01 ? 6.0 / turns : 0.0;
}

// ---- Heat that repeats with the floor --------------------------------------------------------------------------

// Value noise on a lattice that wraps every `period` cells (whole numbers),
// so it repeats exactly. Moving it keeps it repeating.
float pnoise(float2 q, float2 period) {
    float2 i = floor(q), f = fract(q);
    f = f * f * (3.0 - 2.0 * f);
    float a = hash21(float2(wrap(i.x, period.x), wrap(i.y, period.y)));
    float b = hash21(float2(wrap(i.x + 1.0, period.x), wrap(i.y, period.y)));
    float c = hash21(float2(wrap(i.x, period.x), wrap(i.y + 1.0, period.y)));
    float d = hash21(float2(wrap(i.x + 1.0, period.x),
                            wrap(i.y + 1.0, period.y)));
    return mix(mix(a, b, f.x), mix(c, d, f.x), f.y);
}

// Three octaves, each lattice twice as fine and still whole: it repeats too.
float pfbm(float2 uv, float2 period, float2 drift) {
    float v = 0.0, a = 0.5;
    for (int i = 0; i < 3; i++) {
        v += a * pnoise(uv * period + drift, period);
        period *= 2.0;
        drift *= 2.0;
        a *= 0.5;
    }
    return v / 0.875;
}

// ---- The floor -------------------------------------------------------------------------------------------------

float4 shade(float2 uv, constant Uniforms& u, constant Params& p) {
    float2 size = floorSize(u.resolution, p.cells);
    float2 sp = uv * float2(size.x, size.y * SQ3);
    float4 cell = hexCell(sp);
    float2 key = tileKey(cell.zw, size);
    float tt = u.time * p.speed;

    // Lifted a little while it turns, so the grout opens round it.
    float turn = tileTurn(key, tt, p.order, turnEvery(p.turns));
    float lift = sin(3.14159 * fract(turn)) * step(0.001, fract(turn));
    float2 gv = rot(cell.xy, turn * TAU / 6.0) * (1.0 + 0.1 * lift);

    float aa = 1.5 * size.x / u.resolution.x;
    float edge = 0.5 - hexDist(gv);
    float d = truchet(gv);

    // The heat under the floor, flowing, on a lattice of whole tiles.
    float2 period = float2(size.x, size.y * 2.0);
    float flow = pfbm(uv, period, float2(tt * 0.6, tt * 0.25));
    float glowT = 0.12 + p.heat * 0.5 + (flow - 0.35) * 0.8;

    // The clay: a shade of its own for each tile, a bevel lit from the top
    // left, and grout between the tiles where the fire shows through.
    float shadeOf = hash21(key + 11.3);
    float3 clay = mix(float3(0.20, 0.11, 0.08), float3(0.36, 0.22, 0.15),
                      shadeOf);
    float2 nrm = abs(gv.x) > dot(abs(gv), float2(0.5, SQ3 * 0.5))
        ? float2(sign(gv.x), 0.0) : sign(gv) * float2(0.5, SQ3 * 0.5);
    float bevel = 1.0 - smoothstep(0.0, 0.07, edge);
    clay *= 1.0 + bevel * 0.9 * dot(nrm, normalize(float2(-0.6, 0.8)));

    // A light wandering over the floor. Its distance is measured round the
    // picture's edges, as on a torus, so it repeats with the floor too.
    float2 at = float2(0.5 + 0.3 * sin(tt * 0.21), 0.5 + 0.3 * cos(tt * 0.17));
    float2 dl = uv - at;
    dl -= floor(dl + 0.5);
    dl.x *= u.resolution.x / u.resolution.y;
    float light = 0.45 + 0.9 * exp(-dot(dl, dl) * 5.0);
    float3 col = clay * light;

    // The channels: glaze glowing with the heat, spilling light on the clay.
    // Hottest down the middle of the channel.
    float w = mix(0.02, 0.09, p.line);
    float channel = 1.0 - smoothstep(w - aa, w + aa, d);
    float3 hot = kiln(glowT);
    col += hot * exp(-max(d - w, 0.0) * 22.0) * 0.3;
    float core = 1.0 - d / max(w, 1e-3);
    col = mix(col, hot * (0.8 + 0.9 * core), channel);

    // Grout: dark, with the fire glowing deep in it.
    float gap = 0.012 + 0.03 * lift;
    float grout = 1.0 - smoothstep(gap, gap + aa, edge);
    float3 deep = kiln(glowT * 0.75) * 0.5;
    col = mix(col, deep, grout);

    col = col / (1.0 + col * 0.35);
    return float4(col, 1.0);
}

// ---- Sound -----------------------------------------------------------------------------------------------------

// Episode 5's sound hash: a random number from a whole number.
float hashU(uint n) {
    n = (n << 13u) ^ n;
    n = n * (n * n * 15731u + 789221u) + 1376312589u;
    return float(n & 0x7fffffffu) / float(0x7fffffff);
}

// Episode 5's sound noise: random values joined `rate` times a second.
float noise1(float t, float rate, uint seed) {
    float x = t * rate;
    float i = floor(x), f = fract(x);
    f = f * f * (3.0 - 2.0 * f);
    uint k = uint(i) + seed * 7919u;
    return mix(hashU(k), hashU(k + 1u), f) * 2.0 - 1.0;
}

float2 sound(float t, constant Params& p) {
    // The floor as the 16:9 picture lays it: the same tiles, the same turns.
    float2 size = floorSize(float2(16.0, 9.0), p.cells);
    float tt = t * p.speed;
    float every = turnEvery(p.turns);
    float s = max(p.speed, 0.01);

    // Each tile set down after its turn clinks: a ceramic ring, three
    // partials not in tune with each other, its pitch from the tile's number
    // on a pentatonic scale, from where it lies on the floor.
    float2 clinks = float2(0.0);
    if (every > 0.0 && p.speed > 0.001) {
        for (float row = 0.0; row < size.y * 2.0; row += 1.0) {
            for (float col = wrap(row, 2.0); col < size.x * 2.0; col += 2.0) {
                float2 key = float2(col, row);
                float offset = hash21(key + 7.7);
                float k = floor(tt / every - offset);
                float age = (tt - (k + offset) * every - TURN) / s;
                if (k < 0.0 || age < 0.0 || age > 1.2) continue;
                float note = floor(hash21(key + 11.3) * 10.0);
                float semis = floor(note / 5.0) * 12.0
                    + float(int(note) % 5) * 2.4;
                float f = 660.0 * exp2(floor(semis + 0.5) / 12.0);
                float ring = sin(TAU * f * age) * exp(-age * 9.0)
                           + 0.5 * sin(TAU * f * 2.76 * age) * exp(-age * 16.0)
                           + 0.25 * sin(TAU * f * 5.4 * age) * exp(-age * 28.0);
                ring *= smoothstep(0.0, 0.002, age);
                float pan = col / (size.x * 2.0);
                clinks += ring * float2(1.0 - pan, pan);
            }
        }
    }
    clinks *= 0.26;

    // The kiln under the floor: a low drone, a fifth above it, swelling with
    // the heat, and a roar of slow noise.
    float swell = 0.6 + 0.4 * sin(tt * 0.6);
    float drone = sin(TAU * 73.4 * t) + 0.5 * sin(TAU * 110.0 * t + 0.4);
    drone *= 0.24 * (0.4 + 0.6 * p.heat) * swell;
    float roar = noise1(t, 140.0, 1u) * 0.6 + noise1(t, 420.0, 2u) * 0.4;
    roar *= 0.19 * (0.5 + 0.5 * p.heat);

    // The glaze fizzing in its channels: a little bright hiss, more for
    // wider and hotter channels.
    float fizz = noise1(t, 5200.0, 3u) * 0.03 * p.line * (0.3 + p.heat);

    float2 mixdown = float2(drone + roar) + float2(fizz, -fizz) + clinks;
    float fadeIn = smoothstep(0.0, 1.5, t);
    return tanh(mixdown * 1.6) * 0.66 * fadeIn * p.volume;
}

Running it in your own project

Every Ray Kiln shader is the same shape. The standalone download above is the shader with the small wrapper around it that the Ray Kiln host adds, so it compiles with the ordinary Metal compiler and runs in your own app. It defines three entry points: rk_vertex (a full-screen triangle), rk_fragment (calls the shader's shade) and, for shaders with sound, the compute kernel rk_sound.

To draw it, pass the uniforms at fragment buffer 0 and the parameter values at buffer 1, in the order they are declared:

Drawing it, in Swift
struct Uniforms {
    var resolution: SIMD2<Float>
    var time: Float
    var timeDelta: Float
    var mouse: SIMD2<Float>
    var frame: UInt32
    var pad: UInt32 = 0
}

let library = try device.makeLibrary(source: standaloneSource, options: nil)
let descriptor = MTLRenderPipelineDescriptor()
descriptor.vertexFunction = library.makeFunction(name: "rk_vertex")
descriptor.fragmentFunction = library.makeFunction(name: "rk_fragment")
descriptor.colorAttachments[0].pixelFormat = .bgra8Unorm
let pipeline = try device.makeRenderPipelineState(descriptor: descriptor)

// Each frame, inside a render pass:
var uniforms = Uniforms(resolution: size, time: time, timeDelta: dt, mouse: mouse, frame: frame)
var params: [Float] = [0.5, 9.0, 0.5, 0.0, 0.5, 0.5, 0.8]     // the @param defaults, in the order they are declared
encoder.setRenderPipelineState(pipeline)
encoder.setFragmentBytes(&uniforms, length: MemoryLayout<Uniforms>.stride, index: 0)
encoder.setFragmentBytes(&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 Shapes & Symmetry