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:
parent
c44b527fe8
commit
8f5ff3a239
@ -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
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user