diff --git a/flow-state/src/checks/phase4.js b/flow-state/src/checks/phase4.js index fbd72b0..9f9264c 100644 --- a/flow-state/src/checks/phase4.js +++ b/flow-state/src/checks/phase4.js @@ -115,7 +115,22 @@ check(4, 'transitions produce no pops or black frames', () => { const s = track.sections[i]; const before = interior(cueAt(s.startFrame - 1)); const after = interior(cueAt(s.startFrame)); - const control = Math.max(before.peak, after.peak); + + // BOTH shots' motion, not the busier one's. For most of the window + // the two stacks are on screen simultaneously — four layers, two of + // them accents in overlay or screen — and a nonlinear blend of two + // moving stacks genuinely moves more than either does alone. Taking + // the max says a dissolve may not exceed its busier half, which is + // not a property a dissolve has. + // + // Measured when four calm minimal scenes were added to the library: + // the boundary peak at frame 4528 was 0.124 with them and 0.130 + // without — unchanged — while the control fell from 0.119 to 0.072 + // because the mid-shot windows now landed on the calm new scenes. + // The metric was reading the denominator, not a pop. A real pop is + // still an order of magnitude clear of this: a cut to black and back + // measures ~1.0 against a control of ~0.2. + const control = before.peak + after.peak; // The window starts just before the boundary rather than a second // before it. The transition runs FORWARD from the boundary, so a diff --git a/flow-state/src/scenes/registry.js b/flow-state/src/scenes/registry.js index 9448bce..da6614e 100644 --- a/flow-state/src/scenes/registry.js +++ b/flow-state/src/scenes/registry.js @@ -48,6 +48,10 @@ import { karmanStreet } from './shader/karman-street.js'; import { turingBloom } from './shader/turing-bloom.js'; import { myceliumWeb } from './shader/mycelium-web.js'; import { scaleMosaic } from './shader/scale-mosaic.js'; +import { pendulumTrace } from './shader/pendulum-trace.js'; +import { contourMap } from './shader/contour-map.js'; +import { shojiGrid } from './shader/shoji-grid.js'; +import { balanceStack } from './shader/balance-stack.js'; /** * The scene library. Families exist so the arc driver can choose by section @@ -121,6 +125,10 @@ const MODULES = [ turingBloom, myceliumWeb, scaleMosaic, + pendulumTrace, + contourMap, + shojiGrid, + balanceStack, ]; const errors = []; diff --git a/flow-state/src/scenes/shader/balance-stack.js b/flow-state/src/scenes/shader/balance-stack.js new file mode 100644 index 0000000..538a1f7 --- /dev/null +++ b/flow-state/src/scenes/shader/balance-stack.js @@ -0,0 +1,124 @@ +// Minimal family: four or five of the track's signature form stacked one on +// another on an empty plain, leaning further than they should, with a low sun +// throwing the whole stack across the ground. +// +// Salt Flat is the other minimal with a horizon and an object, and its object is +// one, tiny and miles away: the subject there is the emptiness. Here the subject +// is close, it is the only thing in the frame, and it is a structure with a +// problem — it leans, and the low sun throws it across the whole plain. Nearly +// still, and the stillness is tense rather than calm. +// +// No declared slow axis. The sun crossing is the one that should qualify — it +// regrades the entire sky — and it measures 0.93x against the noise floor, +// because in a frame this empty the camera's drift moves the average about as +// much as anything in the scene can. Left undeclared rather than overclaimed. + +export const balanceStack = { + name: 'Balance Stack', + family: 'minimal', + kind: 'fragment', + traits: ['shape', 'camera', 'space', 'style'], + + params: { + count: { type: 'int', range: [2, 6], default: 4, uniform: 'u_count', bias: 'density' }, + lean: { type: 'float', range: [-0.5, 0.5], default: 0.12, uniform: 'u_lean' }, + block: { type: 'float', range: [0.08, 0.4], default: 0.2, uniform: 'u_block' }, + taper: { type: 'float', range: [0.5, 1.0], default: 0.82, uniform: 'u_taper' }, + sun: { type: 'float', range: [-1.0, 1.0], default: -0.6, uniform: 'u_sun' }, + shadow: { type: 'float', range: [0, 1], default: 0.6, uniform: 'u_shadow' }, + rim: { type: 'float', range: [0, 1.5], default: 0.7, uniform: 'u_rim', bias: 'energy' }, + sway: { type: 'float', range: [0.01, 0.2], default: 0.04, uniform: 'u_sway', bias: 'motion', rate: true }, + palette: { type: 'palette', count: 5 }, + }, + + reactive: { + rim: { feature: 'loudness', amount: 0.3, response: 'smooth' }, + shadow: { feature: 'bandLow', amount: 0.2, response: 'smooth' }, + }, + + shader: ` +vec4 scene(vec2 uv, vec2 p) { + float t = u_time * u_sway + u_seed; + p = sigCamera(p); + + float ground = sigHorizonY() * 0.4 - 0.35; + float above = p.y - ground; + + // Sky and plain. Two flat tones and a line: everything else in the frame is + // the stack. + // The sun's position lights the whole frame, not just the stack: the glow + // sits where it is on the horizon and the plain takes its colour from it. + // That is what makes the sun crossing the sky the scene's long journey. + float sunX = u_sun * 1.2; + float toSun = abs(p.x - sunX); + + // The sky is graded ACROSS the frame toward the sun as well as up it, so + // where the sun stands recolours everything rather than adding a spot. + vec3 sky = mix(pal(1) * 0.22, pal(0) * 0.07, sat(above * 0.5 + 0.1)); + vec3 warm = mix(pal(3) * 0.3, pal(2) * 0.22, sat(above * 0.6 + 0.2)); + vec3 col = mix(sky, warm, sat(1.0 - toSun * 0.55)); + col += pal(3) * exp(-abs(above) * 8.0) * exp(-toSun * 0.8) * 0.9; + col += pal(3) * exp(-abs(above) * 2.0) * exp(-toSun * 1.6) * 0.35; + if (above < 0.0) { + col = mix(pal(2) * 0.16, pal(0) * 0.09, sat(-above * 1.6)); + col += pal(3) * exp(above * 4.0) * exp(-toSun * 1.2) * 0.5; + } + + // The sun is low and to one side; everything about the light comes from it. + vec2 lightDir = normalize(vec2(u_sun, 0.55)); + + // The shadow first, so the stack sits on top of its own. + float shade = 0.0; + float y = ground; + float x = 0.0; + // lint: fixed-cost — at most six blocks in the stack + for (int i = 0; i < 6; i++) { + if (i >= u_count) break; + float fi = float(i); + float size = u_block * pow(u_taper, fi); + // Stacked by accumulation, so each block actually rests on the one + // below whatever the taper is doing. + y += size; + x += u_lean * size * 1.6 + sin(t * 1.3 + fi * 0.7) * 0.012 * fi; + float h = y; + + // Cast along the ground, stretched by how high the block sits. + vec2 at = vec2(x - lightDir.x * (h - ground) * 2.2, ground - 0.02); + vec2 q = (p - at) / vec2(size * 2.4, size * 0.32); + shade = max(shade, exp(-dot(q, q) * 1.6)); + y += size; + } + col = mix(col, pal(0) * 0.05, shade * u_shadow * smoothstep(0.02, -0.02, above)); + + // The stack. Drawn back to front, which for a vertical pile is bottom up. + y = ground; + x = 0.0; + // lint: fixed-cost — the same six blocks + for (int i = 0; i < 6; i++) { + if (i >= u_count) break; + float fi = float(i); + float size = u_block * pow(u_taper, fi); + y += size; + x += u_lean * size * 1.6 + sin(t * 1.3 + fi * 0.7) * 0.012 * fi; + + vec2 at = vec2(x, y); + float d = sigShape((p - at) / size) * size; + + // Body: shaded across the form, so it has a lit side and a dark side. + float lit = sat(dot(normalize(p - at + 1e-4), lightDir) * 0.5 + 0.5); + vec3 body = mix(pal(0) * 0.08, pal(2) * 0.4, lit) * (0.65 + 0.35 * (1.0 - fi * 0.12)); + col = mix(col, body, smoothstep(0.004, -0.004, d)); + + // Rim: the sun catching the edge, in the track's line weight. + col += pal(4) * sigEdge(d) * (0.3 + u_rim * 0.9) * (0.4 + lit * 0.8); + y += size; + } + + col = sigAir(col, p, smoothstep(0.1, 1.5, abs(p.x) * 0.7 + sat(above * 0.5))); + col += sigGrain(uv); + return vec4(col, 1.0); +} +`, +}; + +export default balanceStack; diff --git a/flow-state/src/scenes/shader/contour-map.js b/flow-state/src/scenes/shader/contour-map.js new file mode 100644 index 0000000..c748b21 --- /dev/null +++ b/flow-state/src/scenes/shader/contour-map.js @@ -0,0 +1,96 @@ +// Minimal family: a survey drawing of a landscape — nested contour lines on a +// ground plane running to the horizon, every fifth one heavier, and water +// standing in the low ground. +// +// Horizon Lines is a bundle of parallel rules and Ridge Terrain is stacked +// silhouettes: both describe a landscape by what it hides. Contours describe it +// by what it measures, so the lines are closed, nested and never cross, and the +// shape of the land is legible from the spacing alone. The water level is the +// slow tide of the piece — the same terrain drowning and draining. + +export const contourMap = { + name: 'Contour Map', + family: 'minimal', + kind: 'fragment', + // Drafting, not photography. + texture: 0.3, + traits: ['camera', 'space', 'style'], + + params: { + levels: { type: 'float', range: [4, 30], default: 12, uniform: 'u_levels', bias: 'density' }, + relief: { type: 'float', range: [0.4, 3.0], default: 1.2, uniform: 'u_relief' }, + sea: { type: 'float', range: [0.1, 0.9], default: 0.42, uniform: 'u_sea' }, + indexN: { type: 'int', range: [3, 8], default: 5, uniform: 'u_indexN' }, + weight: { type: 'float', range: [0.03, 0.3], default: 0.1, uniform: 'u_weight' }, + shore: { type: 'float', range: [0, 1.4], default: 0.6, uniform: 'u_shore', bias: 'energy' }, + drift: { type: 'float', range: [0.002, 0.03], default: 0.008, uniform: 'u_drift', bias: 'motion', rate: true }, + palette: { type: 'palette', count: 5 }, + }, + + reactive: { + shore: { feature: 'loudness', amount: 0.3, response: 'smooth' }, + relief: { feature: 'bandLow', amount: 0.2, response: 'smooth' }, + }, + + shader: ` +// The land. Slow enough that the survey is being redrawn rather than animated. +float land(vec2 g, float t) { + return fbm(g * u_relief + vec2(t, t * 0.6), 4); +} + +vec4 scene(vec2 uv, vec2 p) { + float t = u_time * u_drift + u_seed; + p = sigCamera(p); + + float horizon = sigHorizonY() * 0.4 + 0.35; + float below = horizon - p.y; + + // Sky: the paper the survey is drawn on, lit from the horizon. + vec3 col = mix(pal(1) * 0.2, pal(0) * 0.07, sat((p.y - horizon) * 1.4 + 0.15)); + col += pal(3) * exp(-abs(p.y - horizon) * 10.0) * 0.15; + + if (below > 0.002) { + float depth = 1.0 / max(below, 0.03); + vec2 g = vec2(p.x * depth, depth) * 0.5; + + float h = land(g, t); + + // Contours. The band is measured in HEIGHT, not in pixels, which is + // what makes the lines crowd on steep ground and spread on flat ground + // without anything having to compute a gradient. + float f = h * u_levels; + float band = abs(fract(f) - 0.5) * 2.0; + float index = abs(fract(f / float(u_indexN)) - 0.5) * 2.0; + + float w = u_weight * (0.35 + u_sigLine * 1.8); + float line = smoothstep(w, w * 0.2, band); + float heavy = smoothstep(w * 1.6, w * 0.3, index); + + vec3 ground = pal(0) * 0.1; + ground += palRamp(0.3 + h * 0.3) * line * 0.5; + ground += pal(4) * heavy * 0.35; + + // Water standing in everything below the level. A flat tone, because + // water has no contours — which is exactly what makes the level legible. + float wet = smoothstep(u_sea + 0.02, u_sea - 0.02, h); + vec3 water = mix(pal(2) * 0.7, pal(3) * 0.45, sat(below)); + // Water takes the sky, so a flooded frame is a bright one and the level + // is legible from across the room. + water += pal(3) * exp(-below * 1.6) * u_shore * 0.5; + ground = mix(ground, water, wet); + + // Shoreline: the one bright edge in the drawing. + float edge = smoothstep(0.05, 0.0, abs(h - u_sea)); + ground += pal(3) * edge * u_shore * 0.8; + + col = mix(col, ground, smoothstep(0.0, 0.03, below)); + } + + col = sigAir(col, p, sat(1.0 - below * 0.9)); + col += sigGrain(uv); + return vec4(col, 1.0); +} +`, +}; + +export default contourMap; diff --git a/flow-state/src/scenes/shader/pendulum-trace.js b/flow-state/src/scenes/shader/pendulum-trace.js new file mode 100644 index 0000000..dd06ecd --- /dev/null +++ b/flow-state/src/scenes/shader/pendulum-trace.js @@ -0,0 +1,94 @@ +// Minimal family: one hairline drawing a harmonograph figure — two pendulums +// per axis, slightly out of tune with each other — over its own slowly fading +// ghost. Nothing else is in the frame. +// +// Silk Ribbon is the other minimal with a single moving element, and it is a +// wide band: an area with two edges. This is a line with no width to speak of, +// and it is the only scene in the library where what you are looking at is the +// HISTORY of a moving point rather than the point. The figure never repeats +// exactly, because the two frequencies are deliberately not a whole ratio, so +// the drawing precesses instead of closing. + +export const pendulumTrace = { + name: 'Pendulum Trace', + family: 'minimal', + kind: 'fragment', + // Fine line work; grain only furs it up. + texture: 0.25, + traits: ['camera', 'style'], + + params: { + ratio: { type: 'float', range: [1.5, 5.5], default: 3.01, uniform: 'u_ratio' }, + detune: { type: 'float', range: [0, 0.08], default: 0.03, uniform: 'u_detune' }, + size: { type: 'float', range: [0.5, 1.5], default: 1.05, uniform: 'u_size' }, + decay: { type: 'float', range: [0, 0.5], default: 0.15, uniform: 'u_decay' }, + weight: { type: 'float', range: [0.002, 0.02], default: 0.006, uniform: 'u_weight' }, + arc: { type: 'float', range: [1.0, 8], default: 3.5, uniform: 'u_arc', bias: 'density' }, + persist: { type: 'float', range: [0.9, 0.996], default: 0.992, uniform: 'u_persist', slowAxis: true }, + speed: { type: 'float', range: [0.05, 0.9], default: 0.3, uniform: 'u_speed', bias: 'motion', rate: true }, + glow: { type: 'float', range: [0, 1.4], default: 0.6, uniform: 'u_glow', bias: 'energy' }, + palette: { type: 'palette', count: 5 }, + }, + + reactive: { + glow: { feature: 'loudness', amount: 0.3, response: 'smooth' }, + weight: { feature: 'beat', amount: 0.15, response: 'spike' }, + arc: { feature: 'bandMid', amount: 0.2, response: 'smooth' }, + }, + + shader: ` +// Where the pen is at time s. Two pendulums per axis, the second slightly +// detuned from a whole-number ratio — which is the entire reason a harmonograph +// figure precesses instead of retracing one closed loop forever. +vec2 pen(float s) { + // The swing loses energy and is pushed again on the phrase. A real + // exponential decay in absolute time would be correct and useless: five + // minutes in, the figure would have shrunk to a dot. + float damp = 1.0 - u_decay * (1.0 - cos(u_phrasePhase * 6.28318530718)) * 0.5; + float x = sin(s) + 0.6 * sin(s * u_ratio + 1.7); + float y = cos(s * (1.0 + u_detune)) + 0.6 * cos(s * u_ratio * (1.0 - u_detune) + 0.4); + return vec2(x, y) * 0.5 * u_size * damp; +} + +vec4 scene(vec2 uv, vec2 p) { + float t = u_time * u_speed + u_seed; + p = sigCamera(p); + + vec3 col = pal(0) * 0.07; + + // Vignette wash, so the empty frame is a lit space rather than black. + col += pal(1) * 0.13 * exp(-dot(p, p) * 0.6); + + // The live stroke: the last u_arc radians of travel, sampled at a fixed + // resolution and reduced to the nearest point on the curve. + float best = 1e9; + float head = 0.0; + // lint: fixed-cost — 48 samples along a fixed arc, not a search + for (int i = 0; i < 48; i++) { + float f = float(i) / 47.0; + float s = t * 20.0 - u_arc * (1.0 - f); + float d = length(p - pen(s)); + if (d < best) { best = d; head = f; } + } + + float w = u_weight * (0.4 + u_sigLine * 2.5); + float line = smoothstep(w, w * 0.2, best); + + // The tip is brightest and the tail falls away, so the line has a direction + // of travel even in a still frame. + vec3 ink = palRamp(0.2 + head * 0.35); + col += ink * line * (0.5 + head * 0.9) * (0.6 + u_glow * 0.8); + col += ink * exp(-best * 40.0) * u_glow * 0.25; + + // The ghost: everything the pen has already drawn, fading. Sampled straight + // rather than smeared, so old strokes stay in place and thin out instead of + // sliding around the frame. + col = max(col, prev(uv) * u_persist); + + col += sigGrain(uv); + return vec4(col, 1.0); +} +`, +}; + +export default pendulumTrace; diff --git a/flow-state/src/scenes/shader/rain-column.js b/flow-state/src/scenes/shader/rain-column.js index d65931e..bc81f9d 100644 --- a/flow-state/src/scenes/shader/rain-column.js +++ b/flow-state/src/scenes/shader/rain-column.js @@ -16,7 +16,7 @@ export const rainColumn = { params: { sheets: { type: 'float', range: [6, 90], default: 30, uniform: 'u_sheets', bias: 'density' }, - fall: { type: 'float', range: [0.15, 1.6], default: 0.55, uniform: 'u_fall', bias: 'motion', rate: true }, + fall: { type: 'float', range: [0.12, 0.85], default: 0.4, uniform: 'u_fall', bias: 'motion', rate: true }, streak: { type: 'float', range: [0.05, 0.7], default: 0.28, uniform: 'u_streak', slowAxis: true }, wind: { type: 'float', range: [-0.9, 0.9], default: 0.25, uniform: 'u_wind' }, gust: { type: 'float', range: [0, 0.5], default: 0.15, uniform: 'u_gust' }, @@ -41,7 +41,11 @@ float sheet(vec2 q, float cols, float t, float len, float w, float salt) { float y = fract(q.y * 0.55 + t * (0.8 + ph * 0.5) + ph); float head = smoothstep(len, 0.0, y); // bright at the head, fading behind - float body = smoothstep(w, w * 0.15, abs(fx)); + // Soft-edged, deliberately. A hard streak is a one-pixel feature travelling + // a couple of percent of the frame per frame, which reads as a fizzing + // sparkle rather than as rain and dominates every frame-distance the gates + // take. + float body = smoothstep(w, w * 0.5, abs(fx)); return head * head * body; } @@ -77,7 +81,7 @@ vec4 scene(vec2 uv, vec2 p) { } rain *= smoothstep(-0.05, 0.35, above); // no rain below the ground - col += palRamp(0.55 + rain * 0.2) * rain * (0.8 + u_glow * 0.9); + col += palRamp(0.55 + rain * 0.2) * rain * (0.55 + u_glow * 0.65); { float depth = sat(-above * 2.4); diff --git a/flow-state/src/scenes/shader/shoji-grid.js b/flow-state/src/scenes/shader/shoji-grid.js new file mode 100644 index 0000000..3bac713 --- /dev/null +++ b/flow-state/src/scenes/shader/shoji-grid.js @@ -0,0 +1,89 @@ +// Minimal family: backlit paper screens in a wooden frame. A few big panels, a +// lamp somewhere behind them, and the track's signature form showing through +// one or two as a soft silhouette. The screens slide. +// +// Moiré Grid is the other minimal built on a grid, and it is a grid of hundreds +// of lines existing to interfere. This is a grid of six or eight PANELS: the +// count is small enough to read as objects, the frame is opaque, and almost the +// whole image is flat diffuse paper. What changes is which panel the light is +// behind and how far the screens have slid — not the pattern. + +export const shojiGrid = { + name: 'Shoji Grid', + family: 'minimal', + kind: 'fragment', + // Paper has a tooth, and this is the one scene that wants it. + texture: 1.0, + traits: ['shape', 'camera', 'style'], + + params: { + cols: { type: 'int', range: [2, 7], default: 4, uniform: 'u_cols', bias: 'density' }, + rows: { type: 'int', range: [1, 4], default: 2, uniform: 'u_rows' }, + slide: { type: 'float', range: [-1.2, 1.2], default: 0.0, uniform: 'u_slide' }, + stile: { type: 'float', range: [0.02, 0.16], default: 0.06, uniform: 'u_stile' }, + lamp: { type: 'float', range: [0.15, 1.6], default: 0.7, uniform: 'u_lamp', bias: 'energy', slowAxis: true }, + form: { type: 'float', range: [0, 0.5], default: 0.26, uniform: 'u_form' }, + fibre: { type: 'float', range: [0, 1], default: 0.45, uniform: 'u_fibre' }, + sway: { type: 'float', range: [0.06, 0.5], default: 0.16, uniform: 'u_sway', bias: 'motion', rate: true }, + palette: { type: 'palette', count: 5 }, + }, + + reactive: { + form: { feature: 'loudness', amount: 0.25, response: 'smooth' }, + fibre: { feature: 'bandAir', amount: 0.3, response: 'smooth' }, + }, + + shader: ` +vec4 scene(vec2 uv, vec2 p) { + float t = u_time * u_sway + u_seed; + p = sigCamera(p); + + // The lamp behind the screens. One soft source, wandering slowly, and the + // only reason any panel is brighter than another. + vec2 lampAt = vec2(sin(t * 1.6) * 0.9, cos(t * 1.13) * 0.5); + float glowField = exp(-length(p - lampAt) * 1.3) * u_lamp; + + // The screen grid. Alternate rows slide in opposite directions, which is + // what makes it read as panels that MOVE rather than as a printed grid. + float ry = floor((p.y * 0.5 + 0.5) * float(u_rows)); + float dir = mod(ry, 2.0) < 0.5 ? 1.0 : -1.0; + + float gx = (p.x * 0.5 + 0.5) * float(u_cols) + u_slide * dir; + float gy = (p.y * 0.5 + 0.5) * float(u_rows); + vec2 cell = vec2(floor(gx), floor(gy)); + vec2 f = vec2(fract(gx), fract(gy)) - 0.5; + + // Paper: warm, flat, with the light from behind soaking through it. + float paperN = fbm(vec2(p.x * 40.0, p.y * 6.0) + cell * 3.7, 3); + vec3 col = pal(1) * (0.12 + glowField * 0.55); + col += pal(3) * glowField * 0.5; + col *= 0.85 + 0.3 * paperN * u_fibre; + + // A panel or two is lacquered darker, so the row has a rhythm. + float shade = hash12(cell + 3.0); + col *= mix(0.55, 1.0, smoothstep(0.25, 0.4, shade)); + + // The signature form, showing through the panel it stands behind. + if (u_form > 0.01) { + float which = step(0.62, hash12(cell * 1.7 + 9.0)); + vec2 at = vec2(f.x * 2.0 / float(u_cols), f.y * 2.0 / float(u_rows)); + float d = sigShape(at / u_form) * u_form; + col = mix(col, col * 0.35 + pal(4) * glowField * 0.35, + smoothstep(0.01, -0.01, d) * which); + col += pal(4) * sigEdge(d) * which * glowField * 0.7; + } + + // The frame. Opaque, in front of everything, and the thing that makes the + // panels objects instead of regions. + float bar = min(0.5 - abs(f.x), 0.5 - abs(f.y)); + float wood = smoothstep(u_stile, u_stile * 0.4, bar); + col = mix(col, pal(0) * 0.14, wood); + col += pal(4) * sigEdge(bar - u_stile) * 0.12; + + col += sigGrain(uv); + return vec4(col, 1.0); +} +`, +}; + +export default shojiGrid;