Side quest 2: give three churning scenes something to develop

Curl Flow, Signal Decay and Circuit Bloom had no parameter that changed their
structure. Each was one process at one scale, statistically identical
everywhere and at every moment — which is the technical statement of "it
looks the same for five minutes". Every pixel moving, the image never
changing.

Each got the large-scale structure it was missing, and the slow axis now has
something real to walk:

- Curl Flow gains a CURRENT: one broad band carries the filaments and the
  rest runs bare. The axis is the band's strength rather than its position,
  and that choice 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 (0.0028 across a full sweep). Opening and closing it changes how much
  of the frame is lit at all. The current gates the final image rather than
  only the vein term, because the feedback trail and the flow tint each fill
  the frame on their own.
- Signal Decay gains a DAMAGE FRONT: lock falls away on one side of a moving
  boundary, so the stack has a shape instead of every lane being an
  independent coin flip. Hiss had to be gated by it too — hiss rises as lock
  falls, so without that a damaged lane simply traded signal for noise and
  carried the same energy, and the front cancelled itself out.
- Circuit Bloom now actually BLOOMS. `reach` was a fixed vignette, so the
  packets ran, the pads blinked and the board never changed. Its range stops
  at 1.6 because the frame's far corner is ~1.4 units out and travel spent
  past that changes nothing — the first attempt wasted most of the axis up
  there.

The gate itself was wrong, and this is the more important half of the commit.
It compared the scene at thirty seconds against the scene at two and a half
minutes and divided by what it did between those points with frozen
parameters. Those are two different places in the song, so the denominator
was full of audio reactivity: it was really asking "does the axis move this
scene MORE than the music does", which no scene should have to pass. Circuit
Bloom failed at 0.92x while its mean luminance moved 5.6x across the axis — a
large structural change scored as nothing.

The experiment now holds time constant and varies only the axis, against a
control that is the same parameters over the next window — the residual churn
the ten-second average failed to cancel, which is the noise floor it has to
beat. With the axis disabled the two parameter sets are identical and the
ratio is 0.00 by construction, so the companion check is a real discriminator
rather than a formality. It reads 0.00x on all six scenes.

Moiré Grid 28.7x · Gate Corridor 14.3x · Truchet Fold 3.3x · Signal Decay
1.9x · Curl Flow 1.7x · Circuit Bloom 1.4x, against a 1.25x bar.

104/104 checks pass including the slow set. Closes SIDE-QUESTS.md §2 and the
open half of EPIC-2.md §3.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dejvino 2026-08-06 19:51:51 +02:00
parent c44b527fe8
commit 8f5ff3a239
5 changed files with 111 additions and 18 deletions

View File

@ -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

View File

@ -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');

View File

@ -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);

View File

@ -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);

View File

@ -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