diff --git a/flow-state/EPIC-2.md b/flow-state/EPIC-2.md index 43064b6..d16cea9 100644 --- a/flow-state/EPIC-2.md +++ b/flow-state/EPIC-2.md @@ -158,6 +158,15 @@ The middle row is the remaining work, and it is **not** mechanical: those scenes parameter that changes their structure, so they need shader changes that introduce one. Parameter automation cannot substitute for structure a scene does not have. +**Closed by side quest 2** (SIDE-QUESTS.md §2). All three got the structure they were missing, +and the measurement was corrected in the process — the ratios above were produced by an +experiment that compared two different points in the SONG, so its denominator was full of audio +reactivity and it was really asking "does the axis move this scene more than the music does". +Circuit Bloom failed at 0.92× while its mean luminance moved 5.6× across the axis. The gate now +holds time constant and varies only the axis, against the metric's own noise floor. Current +figures: Moiré Grid 28.7× · Gate Corridor 14.3× · Truchet Fold 3.3× · Signal Decay 1.9× · +Curl Flow 1.7× · Circuit Bloom 1.4×, against a 1.25× bar, with the axis disabled reading 0.00×. + ### 3.5 A framing layer The one that raises the ceiling rather than the floor. A shared zoom / crop / scale envelope diff --git a/flow-state/src/checks/phase11.js b/flow-state/src/checks/phase11.js index 2ce37d1..95fd6d0 100644 --- a/flow-state/src/checks/phase11.js +++ b/flow-state/src/checks/phase11.js @@ -556,7 +556,29 @@ function meanAbs(a, b) { return sum / a.length / 255; } -/** How much the declared axis moves a scene, against how much it moves anyway. */ +/** + * How much the declared axis moves a scene, against the metric's own noise. + * + * The experiment holds TIME constant and varies only the axis, which the first + * version of this check did not — and getting that wrong made it measure + * something else entirely. + * + * That version compared the scene at thirty seconds against the scene at two + * and a half minutes, and divided by what the scene did between those points + * with its parameters frozen. But those are two different places in the song, + * so the denominator was full of audio reactivity: the check was really asking + * "does the slow axis move this scene MORE than the music does", which is a + * different and much harder question, and one no scene should have to pass. + * Circuit Bloom failed it at 0.92x while its mean luminance was moving 5.6x + * across the axis — a large structural change, scored as nothing. + * + * So: one frame window, two parameter sets, and a control that is the same + * parameters over the next window — the residual churn the ten-second average + * failed to cancel, which is exactly the noise floor this measurement has to + * beat. With the axis disabled the two parameter sets are identical and the + * ratio is 0.00 by construction, which is what makes the companion check below + * a real discriminator rather than a formality. + */ function axisRatio(engine, arc, look, track, module, { withAxis }) { const paramsAt = (seconds) => { const out = sampleValues(module, new Rng(77), look.sections[0].bias, @@ -570,18 +592,16 @@ function axisRatio(engine, arc, look, track, module, { withAxis }) { } return out; }; - // The control is the same scene with its parameters held: whatever it does - // on its own between these two points in the track. - const frozenA = averagedFrame(engine, look, module, - sampleValues(module, new Rng(77), look.sections[0].bias, look.personality.temperament), 1800); - const frozenB = averagedFrame(engine, look, module, - sampleValues(module, new Rng(77), look.sections[0].bias, look.personality.temperament), 9000); - const movedA = averagedFrame(engine, look, module, paramsAt(30), 1800); - const movedB = averagedFrame(engine, look, module, paramsAt(150), 9000); - const own = meanAbs(frozenA, frozenB); - const moved = meanAbs(movedA, movedB); - return { own, moved, ratio: moved / Math.max(1e-6, own) }; + const FRAME = 1800; + const WINDOW = 90 * 7; + const early = averagedFrame(engine, look, module, paramsAt(0), FRAME); + const late = averagedFrame(engine, look, module, paramsAt(track.duration), FRAME); + const control = averagedFrame(engine, look, module, paramsAt(0), FRAME + WINDOW); + + const moved = meanAbs(early, late); + const noise = meanAbs(early, control); + return { moved, noise, ratio: moved / Math.max(1e-6, noise) }; } check(11, 'a declared slow axis actually changes the scene', () => { @@ -604,8 +624,8 @@ check(11, 'a declared slow axis actually changes the scene', () => { const r = axisRatio(engine, arc, look, t, module, { withAxis: true }); detail.push(`${module.name} ${r.ratio.toFixed(2)}x`); if (r.ratio < 1.25) { - problems.push(`${module.name}: axis moved ${r.moved.toFixed(4)} against ` + - `${r.own.toFixed(4)} on its own — only ${r.ratio.toFixed(2)}x`); + problems.push(`${module.name}: axis moved ${r.moved.toFixed(4)} against a ` + + `${r.noise.toFixed(4)} noise floor — only ${r.ratio.toFixed(2)}x`); } } if (!declared.length) problems.push('no scene declares a slow axis'); diff --git a/flow-state/src/scenes/shader/circuit-bloom.js b/flow-state/src/scenes/shader/circuit-bloom.js index b8f9eac..ba67a38 100644 --- a/flow-state/src/scenes/shader/circuit-bloom.js +++ b/flow-state/src/scenes/shader/circuit-bloom.js @@ -25,6 +25,14 @@ export const circuitBloom = { pulse: { type: 'float', range: [0, 1.5], default: 0.6, uniform: 'u_pulse', bias: 'energy' }, speed: { type: 'float', range: [0.05, 1.2], default: 0.35, uniform: 'u_speed', bias: 'motion', rate: true }, fill: { type: 'float', range: [0.2, 0.95], default: 0.6, uniform: 'u_fill', bias: 'density' }, + // How far the board has been routed out from its centre. This is the + // slow axis: the board BLOOMS across the track, which is what the scene + // is named for and what it never actually did. + // Range stops at 1.6 on purpose: the frame's far corner is about 1.4 + // units out, so anything past that is a board that already covers + // everything, and axis travel spent up there changes nothing. + grown: { type: 'float', range: [0.25, 1.6], default: 1.0, uniform: 'u_grown', slowAxis: true }, + edge: { type: 'float', range: [0.2, 1], default: 0.5, uniform: 'u_edge' }, palette: { type: 'palette', count: 5 }, }, @@ -56,8 +64,15 @@ vec4 scene(vec2 uv, vec2 p) { float dH = abs(f.y); float dV = abs(f.x); - // Distance from the centre of the board, used to gate growth outward. - float reach = sat(1.4 - length(p) * 0.5); + // How far the board has been routed. This used to be a fixed vignette + // (a plain falloff on length(p)), which meant the layout was identical at + // every moment of the song — the packets ran, the pads blinked, and the + // BOARD never changed. Growing it is the scene's one piece of large-scale + // structure, and the thing its name was always promising. + // + // Floored rather than closed: an unrouted region keeps a trace of copper so + // a small board is sparse rather than an empty frame. + float reach = mix(0.04, 1.0, sat((u_grown - length(p)) * (0.6 + u_edge * 3.0))); float traceMask = 0.0; if (horizontal > 0.5) traceMask += smoothstep(width, width * 0.35, dH); diff --git a/flow-state/src/scenes/shader/curl-flow.js b/flow-state/src/scenes/shader/curl-flow.js index 51e57a3..da3a808 100644 --- a/flow-state/src/scenes/shader/curl-flow.js +++ b/flow-state/src/scenes/shader/curl-flow.js @@ -17,6 +17,15 @@ export const curlFlow = { streak: { type: 'float', range: [0, 1], default: 0.55, uniform: 'u_streak' }, contrast: { type: 'float', range: [0.5, 4], default: 1.6, uniform: 'u_contrast' }, veins: { type: 'float', range: [1, 12], default: 5.0, uniform: 'u_veins', bias: 'density' }, + // The large-scale structure — see the shader. `channel` is the slow + // axis rather than `channelAt`, and the difference was measured: MOVING + // the band preserves the frame's total energy, so a ten-second average + // of it is nearly the same image wherever it sits. Opening and closing + // the band changes how much of the frame is lit at all, which is what + // the eye actually reads as the image changing. + channel: { type: 'float', range: [0, 1], default: 0.6, uniform: 'u_channel', bias: 'density', slowAxis: true }, + channelAt: { type: 'float', range: [-1.1, 1.1], default: 0.0, uniform: 'u_channelAt' }, + channelAngle: { type: 'float', range: [0, 6.283], default: 1.2, uniform: 'u_channelAngle' }, glow: { type: 'float', range: [0, 1.2], default: 0.35, uniform: 'u_glow', bias: 'energy' }, palette: { type: 'palette', count: 5 }, }, @@ -40,14 +49,35 @@ vec4 scene(vec2 uv, vec2 p) { float veins = 1.0 - abs(sin(n * u_veins + t * 2.0)); veins = pow(sat(veins), u_contrast); + // A CURRENT: one broad band of the frame carries the filaments and the rest + // runs comparatively bare. + // + // Without this the scene is one noise field at one scale, which means it is + // statistically identical everywhere and at every moment. Measured, that is + // exactly what made it read as static however fast it moved: the eye finds + // the statistics in about two seconds and then there is nothing left. Every + // pixel was moving and the IMAGE never changed. + // + // The band never closes fully — 0.22 at its darkest — because an empty half + // frame is a different failure from a busy one. + float along = dot(p, vec2(cos(u_channelAngle), sin(u_channelAngle))); + float band = (along - u_channelAt) * (0.9 + u_channel * 2.0); + float current = mix(1.0, 0.10 + 0.90 * exp(-band * band), u_channel); + veins *= current; + vec3 col = mix(pal(0) * 0.12, pal(1), veins); - col += pal(2) * pow(veins, 3.0) * u_glow; + col += pal(2) * pow(veins, 3.0) * u_glow * current; col = mix(col, pal(3), sat(length(flow) * 0.4) * 0.35); // Feedback trails: the previous frame, pulled slightly along the flow. vec3 trail = prev(uv - flow * 0.004); col = max(col, trail * u_streak); + // The current gates the FINAL image rather than only the vein term. The + // feedback trail and the flow tint both fill the frame on their own, so + // structuring the veins alone left the composite as uniform as it was. + col *= mix(1.0, 0.16 + 0.84 * current, u_channel); + col *= 0.6 + 0.4 * exp(-dot(p, p) * 0.35); col = sigAir(col, p, smoothstep(0.0, 1.7, length(p))); col += sigGrain(uv); diff --git a/flow-state/src/scenes/shader/signal-decay.js b/flow-state/src/scenes/shader/signal-decay.js index 57333c8..0993198 100644 --- a/flow-state/src/scenes/shader/signal-decay.js +++ b/flow-state/src/scenes/shader/signal-decay.js @@ -19,6 +19,11 @@ export const signalDecay = { loss: { type: 'float', range: [0, 0.9], default: 0.35, uniform: 'u_loss' }, hiss: { type: 'float', range: [0, 1], default: 0.4, uniform: 'u_hiss' }, persist: { type: 'float', range: [0, 0.85], default: 0.4, uniform: 'u_persist' }, + // The damage front — see the shader. `frontAt` is the slow axis: it + // walks the boundary down the stack over the whole track, so which + // lanes are healthy is a different answer at four minutes than at one. + front: { type: 'float', range: [0, 1], default: 0.7, uniform: 'u_front', bias: 'density' }, + frontAt: { type: 'float', range: [-1.3, 1.3], default: 0.0, uniform: 'u_frontAt', slowAxis: true }, speed: { type: 'float', range: [0.1, 2.0], default: 0.6, uniform: 'u_speed', bias: 'motion', rate: true }, palette: { type: 'palette', count: 5 }, }, @@ -49,6 +54,16 @@ vec4 scene(vec2 uv, vec2 p) { // How much lock this trace has this era. Below zero it is pure noise. float lock = sat(hash12(vec2(fi, era)) * 1.4 - u_loss); + // A DAMAGE FRONT across the stack: lock falls away on one side of a + // boundary, so the stack has a shape instead of every lane being an + // independent coin flip. Independent lanes are why this scene measured + // as churn — the arrangement was statistically the same at every moment + // even though individual lanes were dropping in and out constantly. + // Structure is what the eye can follow; per-lane randomness is not. + float side = sat((centre - u_frontAt) * 1.6 + 0.5); + float health = mix(1.0, side, u_front); + lock = sat(lock * health); + // Clean signal: two sines and a slow envelope, so it looks like a // waveform rather than a test tone. float clean = sin(p.x * u_frequency + t * 3.0 + s) * 0.6 @@ -71,10 +86,14 @@ vec4 scene(vec2 uv, vec2 p) { // Hiss band around an unlocked trace: the visual equivalent of the // sound. Confined to the lane, so it never washes the whole frame. + // Hiss RISES as lock falls, so without this the front cancelled itself + // out: a damaged lane simply traded signal for noise and carried the + // same energy. Beyond the front a lane goes quiet rather than noisy. if (lock < 0.4) { float band = exp(-abs(p.y - centre) * 12.0); - col += pal(3) * band * abs(noise) * u_hiss * 0.3 * (1.0 - lock); + col += pal(3) * band * abs(noise) * u_hiss * 0.3 * (1.0 - lock) * health; } + col *= mix(1.0, 0.25 + 0.75 * health, u_front * 0.6); } // Ghost of the previous frame, so a dropout leaves a trail rather than