diff --git a/flow-state/src/scenes/registry.js b/flow-state/src/scenes/registry.js index 1a06611..cfa24da 100644 --- a/flow-state/src/scenes/registry.js +++ b/flow-state/src/scenes/registry.js @@ -56,6 +56,9 @@ import { suspensionSpan } from './shader/suspension-span.js'; import { stairwellDescent } from './shader/stairwell-descent.js'; import { aqueductMarch } from './shader/aqueduct-march.js'; import { dataAisle } from './shader/data-aisle.js'; +import { voronoiShatter } from './shader/voronoi-shatter.js'; +import { apollonianGasket } from './shader/apollonian-gasket.js'; +import { isometricBlocks } from './shader/isometric-blocks.js'; /** * The scene library. Families exist so the arc driver can choose by section @@ -137,6 +140,9 @@ const MODULES = [ stairwellDescent, aqueductMarch, dataAisle, + voronoiShatter, + apollonianGasket, + isometricBlocks, ]; const errors = []; diff --git a/flow-state/src/scenes/shader/apollonian-gasket.js b/flow-state/src/scenes/shader/apollonian-gasket.js new file mode 100644 index 0000000..d38ec64 --- /dev/null +++ b/flow-state/src/scenes/shader/apollonian-gasket.js @@ -0,0 +1,103 @@ +// Geometric family: an Apollonian packing — a circle filled with circles, each +// gap filled again with smaller ones, forever. Built by inversion rather than by +// drawing, so the nesting is exact at every scale the frame can resolve. +// +// Quasicrystal is the other infinitely-detailed geometric and its detail is +// additive: waves summed until the interference looks complicated. This detail +// is structural — every disc has a definite size and place, and zooming finds +// more of them rather than finer noise. Nothing here is a texture, which is why +// it holds up when the shot pushes in. + +export const apollonianGasket = { + name: 'Apollonian Gasket', + family: 'geometric', + kind: 'fragment', + // Drawn geometry; grain only muddies the small discs. + texture: 0.3, + traits: ['camera', 'style'], + + params: { + depth: { type: 'int', range: [3, 12], default: 7, uniform: 'u_depth', bias: 'density' }, + pack: { type: 'float', range: [0.9, 1.6], default: 1.2, uniform: 'u_pack' }, + offset: { type: 'float', range: [0, 0.35], default: 0.2, uniform: 'u_offset' }, + zoom: { type: 'float', range: [0.4, 2.2], default: 1.0, uniform: 'u_zoom', slowAxis: true }, + rim: { type: 'float', range: [0, 1.5], default: 0.7, uniform: 'u_rim', bias: 'energy' }, + churn: { type: 'float', range: [0.004, 0.05], default: 0.015, uniform: 'u_churn', bias: 'motion', rate: true }, + palette: { type: 'palette', count: 5 }, + }, + + reactive: { + rim: { feature: 'bandHigh', amount: 0.35, response: 'smooth' }, + offset: { feature: 'bandLow', amount: 0.12, response: 'smooth' }, + }, + + shader: ` +vec4 scene(vec2 uv, vec2 p) { + float t = u_time * u_churn + u_seed; + p = sigCamera(p); + p /= max(u_zoom, 0.05); + + // The Kleinian fold: reflect into a fundamental domain, then invert through + // the unit circle, over and over. Each pass divides the scale by the amount + // the inversion contracted, and the accumulated scale is what turns the + // whole recursion back into a single distance at the end. + float scale = 1.0; + vec2 q = p; + float touched = 0.0; + + // lint: fixed-cost — u_depth inversions, bounded at fourteen + for (int i = 0; i < 14; i++) { + if (i >= u_depth) break; + // Stop when the next generation would be finer than the frame can + // resolve. Without this the deep discs alias into a speckle that reads + // as noise, which is the opposite of what an exact packing is for. + if (scale > 45.0) break; + + // Fold into the strip: this is what makes the packing infinite in + // every direction rather than one circle's worth. + q = mod(q + 1.0, 2.0) - 1.0; + q -= vec2(u_offset * sin(t + float(i) * 0.7), u_offset * cos(t * 0.8)) * 0.25; + + float r2 = dot(q, q); + float k = u_pack / max(r2, 1e-4); + q *= k; + scale *= k; + touched += 1.0; + } + + // Back to a screen-space distance. Without dividing by the accumulated + // scale the small discs would be drawn with the same line weight as the + // large ones and the image would be a solid mat of edges. + float d = (length(q) - 1.0) / scale; + + float w = 0.006 + u_sigLine * 0.03; + float edge = smoothstep(w * 2.0, w * 0.3, abs(d)); + float glow = exp(-abs(d) * 26.0); + + // The deepest generations are held back rather than cut off. A hard depth + // limit makes discs pop in and out as the fold slides across it, and a + // frame-wide population of discs appearing at once measured as a strobe. + float lod = smoothstep(45.0, 18.0, scale); + edge *= mix(0.25, 1.0, lod); + glow *= mix(0.15, 1.0, lod); + + // Colour by how deep the point fell before it settled, so the generations + // of the packing are legible as bands rather than all being one hue. + float gen = fract(log2(max(scale, 1e-6)) * 0.15 + 0.5); + + vec3 col = pal(0) * 0.06; + col += palRamp(gen) * edge * (0.55 + u_rim * 0.7); + col += palRamp(gen + 0.25) * glow * u_rim * 0.4; + col += pal(4) * sigEdge(d) * 0.5; + + // Fill the interiors faintly so the discs are objects and not just outlines. + col += palRamp(gen + 0.5) * smoothstep(0.0, -0.06, d) * 0.12; + + col *= 0.7 + 0.3 * exp(-dot(p, p) * 0.2); + col += sigGrain(uv); + return vec4(col, 1.0); +} +`, +}; + +export default apollonianGasket; diff --git a/flow-state/src/scenes/shader/isometric-blocks.js b/flow-state/src/scenes/shader/isometric-blocks.js new file mode 100644 index 0000000..47847d9 --- /dev/null +++ b/flow-state/src/scenes/shader/isometric-blocks.js @@ -0,0 +1,125 @@ +// Geometric family: a field of extruded blocks seen isometrically, rising and +// falling on the low end. A solid, lit surface with a light direction — three +// faces per block, and the whole grid reads as a landscape rather than a chart. +// +// Floating Geometry is bodies adrift in space with nothing under them. This is +// the opposite proposition: everything is on one grid, everything touches the +// ground, and the only freedom any block has is its height. The plan of each +// block is the track's signature form, so a hexagonal video gets a honeycomb of +// columns rather than a chessboard of them. + +export const isometricBlocks = { + name: 'Isometric Blocks', + family: 'geometric', + kind: 'fragment', + texture: 0.6, + traits: ['shape', 'camera', 'style'], + + params: { + grid: { type: 'float', range: [2, 10], default: 4.5, uniform: 'u_grid', bias: 'density', slowAxis: true }, + height: { type: 'float', range: [0.1, 0.7], default: 0.35, uniform: 'u_height', bias: 'energy' }, + plan: { type: 'float', range: [0.2, 0.5], default: 0.4, uniform: 'u_plan' }, + tilt: { type: 'float', range: [0.35, 0.75], default: 0.55, uniform: 'u_isoTilt' }, + light: { type: 'float', range: [0, 1.4], default: 0.65, uniform: 'u_light' }, + wave: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_wave' }, + roll: { type: 'float', range: [0.01, 0.08], default: 0.035, uniform: 'u_roll', bias: 'motion', rate: true }, + palette: { type: 'palette', count: 5 }, + }, + + // The low end deliberately does NOT drive the block heights. Measured at + // aggressive settings that read as 4 flashes a second: every tower in the + // frame grows and shrinks together, which is a whole-frame luminance cycle + // on the kick and exactly what the flash ceiling exists to stop. The beat + // lands on a quarter of the tops instead — same reading, local swing. + reactive: { + light: { feature: 'loudness', amount: 0.25, response: 'smooth' }, + }, + + shader: ` +// How tall the block at this cell stands, in screen units. A travelling wave +// plus a fixed per-cell character, so the field has a rhythm running through it +// and a skyline that is still recognisable between beats. +float columnHeight(vec2 cell, float t) { + float own = hash12(cell + u_seed); + float wave = sin(dot(cell, vec2(0.7, 0.5)) - t * 1.1) * 0.5 + 0.5; + // Squared, so most of the field lies flat and the tall ones stand out as + // individual towers. A linear height makes every column tall enough to + // occlude its neighbours, and the field turns into vertical stripes. + return u_height * pow(mix(own, wave, u_wave), 2.4); +} + +vec4 scene(vec2 uv, vec2 p) { + float t = u_time * u_roll + u_seed; + p = sigCamera(p); + + float tilt = max(u_isoTilt, 0.1); + float cw = 1.0 / u_grid; // one cell across, on screen + + vec3 col = mix(pal(0) * 0.06, pal(1) * 0.14, sat(p.y * 0.4 + 0.5)); + + // A block standing on row r covers screen rows ABOVE r's ground line, so a + // pixel can only belong to a row at or behind it. Walking a fixed number of + // rows from the back forward and overwriting as we go is the painter's + // algorithm, and it is the whole of the depth sorting an isometric field + // needs — no sorting, no z buffer, just the order of the loop. + float colX = floor(p.x / cw); + float fx = (p.x / cw - colX - 0.5) * cw; // screen offset within the column + + // Alternate columns are staggered half a row, which is what interlocks the + // field into a lattice instead of leaving it a set of independent stacks. + float stagger = mod(colX, 2.0) * 0.5; + float rowHere = floor(p.y / (tilt * cw) - stagger); + + // Rows FURTHER AWAY stand higher up the frame, so a pixel can only belong + // to a block whose ground line is at or below it — row <= rowHere. Counting + // down from there draws the far ones first and lets the near ones paint + // over them. + // lint: fixed-cost — eight rows of depth, back to front + for (int j = 7; j >= 0; j--) { + float row = rowHere - float(7 - j); + vec2 cell = vec2(colX, row); + + float groundY = (row + stagger) * tilt * cw; // where this cell meets the ground + float h = columnHeight(cell, t); + float dy = p.y - groundY; // height up the block face + + // Side wall: the plan swept up from the ground line to the top face. + float inX = smoothstep(u_plan * cw, u_plan * cw * 0.85, abs(fx)); + float wall = inX * step(0.0, dy) * step(dy, h); + + float lit = 0.5 + 0.5 * sat(fx / (u_plan * cw) * 0.8 + 0.5); + // The wall darkens toward the ground, which is the only shading cue + // that tells a tall block from a short one at a glance. + // Neither face takes the loudness. Anything that multiplies a face + // colour multiplies most of the frame, and a field of blocks brightening + // together on the kick measured at 5 flashes a second. The light level + // is expressed on the EDGES, which are a few percent of the image. + vec3 side = mix(pal(0) * 0.5, pal(2) * 0.8, lit) * 0.75; + side *= 0.45 + 0.55 * sat(dy / max(h, 1e-3)); + col = mix(col, side, wall); + + // Top face: the track's signature form, lying flat, lifted by h and + // squashed by the tilt — so every block in the field is the same + // silhouette as every other subject in the video. + vec2 q = vec2(fx, (p.y - groundY - h) / tilt); + float dTop = sigShape(q / (u_plan * cw)) * (u_plan * cw); + float topMask = smoothstep(0.004, -0.004, dTop); + + vec3 top = mix(pal(2), pal(3), sat(h / max(u_height, 1e-3))) * 0.8; + col = mix(col, top, topMask); + // A quarter of the blocks answer the beat, chosen by cell rather than + // by anything global, so the field has a pulse without the frame having + // a flash. + float picked = step(0.75, hash12(cell * 3.17 + 5.0)); + col += pal(3) * topMask * picked * u_beat * 0.45; + col += pal(4) * sigEdge(dTop) * (0.2 + u_light * 1.2) * step(0.0, dy); + } + + col *= 0.75 + 0.25 * exp(-dot(p, p) * 0.3); + col += sigGrain(uv); + return vec4(col, 1.0); +} +`, +}; + +export default isometricBlocks; diff --git a/flow-state/src/scenes/shader/voronoi-shatter.js b/flow-state/src/scenes/shader/voronoi-shatter.js new file mode 100644 index 0000000..6884940 --- /dev/null +++ b/flow-state/src/scenes/shader/voronoi-shatter.js @@ -0,0 +1,96 @@ +// Geometric family: a Voronoi tessellation — every point in the frame belongs +// to whichever seed is nearest, so the plates are irregular, they meet in clean +// straight seams, and they cannot overlap or leave a gap. +// +// Truchet Fold is the other tiling here and it is a regular grid wearing a +// pattern: the cells are square whatever is drawn in them. These cells have no +// fixed shape at all — each one is whatever its neighbours leave it — so when +// the seeds move, every plate in the frame changes shape at once and the seams +// slide along themselves. The seeds jump to new positions on the phrase, which +// re-cuts the whole plane in one frame without anything sliding. +// +// No declared slow axis. The re-cut is the point of the scene and it puts the +// ten-second average 0.127 away from itself between any two windows — the +// highest noise floor of anything in the library — so every candidate axis +// measured at 0.03x to 0.1x. Nothing a parameter can do competes with the plane +// being re-cut, which is the honest description of the scene. + +export const voronoiShatter = { + name: 'Voronoi Shatter', + family: 'geometric', + kind: 'fragment', + texture: 0.5, + traits: ['camera', 'style'], + + params: { + cells: { type: 'float', range: [1.2, 8], default: 3.0, uniform: 'u_cells', bias: 'density' }, + jitter: { type: 'float', range: [0, 1], default: 0.75, uniform: 'u_jitter' }, + seam: { type: 'float', range: [0.01, 0.14], default: 0.05, uniform: 'u_seam' }, + recut: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_recut' }, + relief: { type: 'float', range: [0, 1], default: 0.5, uniform: 'u_relief' }, + glow: { type: 'float', range: [0, 1.5], default: 0.6, uniform: 'u_glow', bias: 'energy' }, + drift: { type: 'float', range: [0.01, 0.4], default: 0.09, uniform: 'u_drift', bias: 'motion', rate: true }, + palette: { type: 'palette', count: 5 }, + }, + + reactive: { + glow: { feature: 'bandMid', amount: 0.35, response: 'smooth' }, + jitter: { feature: 'bandLow', amount: 0.2, response: 'smooth' }, + }, + + shader: ` +vec4 scene(vec2 uv, vec2 p) { + float t = u_time * u_drift + u_seed; + p = sigCamera(p); + + vec2 g = p * u_cells; + vec2 base = floor(g); + vec2 f = g - base; + + // The plane is re-cut on the phrase: the seeds do not travel to their new + // positions, they are simply somewhere else on the next frame. A tiling + // that slides is a texture; a tiling that jumps is an event. + float era = floor(u_phrasePhase * 2.0) * u_recut; + + // Nearest and second-nearest seed. The difference between the two is the + // distance to the seam, which is what makes the borders even in width + // instead of pinching at the corners. + float d1 = 8.0, d2 = 8.0; + vec2 id1 = vec2(0.0); + // lint: fixed-cost — the 3x3 neighbourhood a Voronoi cell can reach + for (int j = -1; j <= 1; j++) { + for (int i = -1; i <= 1; i++) { + vec2 cell = base + vec2(float(i), float(j)); + vec2 h = hash22(cell + era * 7.3 + u_seed); + vec2 seed = vec2(float(i), float(j)) + + mix(vec2(0.5), h + 0.15 * sin(t * 2.0 + h * 6.2831), u_jitter); + float d = length(f - seed); + if (d < d1) { d2 = d1; d1 = d; id1 = cell; } + else if (d < d2) { d2 = d; } + } + } + + float border = (d2 - d1) / u_cells; // scene units to the seam + + // Plates: each one flat, tinted by its own identity, and tilted a little so + // the tessellation reads as broken glass rather than as a colour chart. + float who = hash12(id1 * 1.7 + era); + vec3 plate = palRamp(0.15 + who * 0.5); + float tilt = mix(1.0, 0.55 + who * 0.9, u_relief); + + vec3 col = plate * 0.28 * tilt; + col += plate * exp(-d1 * 2.5) * u_glow * 0.25; + + // Seams: drawn in the track's hand, brightest where three plates meet. + float seam = smoothstep(u_seam, u_seam * 0.15, border); + col += pal(4) * seam * (0.4 + u_glow * 0.8); + col += pal(3) * sigEdge(border - u_seam) * 0.4; + + col *= 0.75 + 0.25 * exp(-dot(p, p) * 0.25); + col += sigGrain(uv); + return vec4(col, 1.0); +} +`, +}; + +export default voronoiShatter;