diff --git a/flow-state/src/checks/main.js b/flow-state/src/checks/main.js index f254859..fb81a1c 100644 --- a/flow-state/src/checks/main.js +++ b/flow-state/src/checks/main.js @@ -108,6 +108,7 @@ async function main() { const { lines, ok, headline } = await experimentReportLines({ songs: Number(params.get('count')) || 6, probes: Number(params.get('probes')) || 4, + repeats: Number(params.get('repeats')) || 3, }); out.innerHTML = `
${lines.join('\n')}
`; summaryEl.textContent = `${headline} · ${((Date.now() - started) / 1000).toFixed(1)}s`; diff --git a/flow-state/src/checks/variety/print.js b/flow-state/src/checks/variety/print.js index 26b1858..a6df653 100644 --- a/flow-state/src/checks/variety/print.js +++ b/flow-state/src/checks/variety/print.js @@ -209,7 +209,7 @@ export async function songVarietyReportLines({ songs = 6, probes = 5 } = {}) { * their own content. Three arms, because two would not distinguish "stages are * better" from "a small pool is better". */ -export async function experimentReportLines({ songs = 6, probes = 4 } = {}) { +export async function experimentReportLines({ songs = 6, probes = 4, repeats = 3 } = {}) { const { scenes } = await import('../../scenes/registry.js'); const byName = (n) => scenes.find((m) => m.name === n); @@ -239,38 +239,59 @@ export async function experimentReportLines({ songs = 6, probes = 4 } = {}) { lines.push(' Higher observed = two songs that look like different work.'); lines.push(''); + // Repeats, with error bars. The first run of this comparison used seven + // songs and put stages 14% ahead; at twelve songs the ordering flipped. A + // difference that changes sign with the sample is a difference that has to + // be reported with its spread or not at all. const results = []; for (const arm of arms) { - await new Promise((r) => setTimeout(r, 0)); - const r = measureSongVariety({ songs, probes, pool: arm.pool }); - results.push({ arm, r }); + const runs = []; + for (let k = 0; k < repeats; k++) { + await new Promise((r) => setTimeout(r, 0)); + runs.push(measureSongVariety({ + songs, probes, pool: arm.pool, seedSalt: k * 7919, + })); + } + results.push({ arm, runs, r: runs[0] }); } - lines.push(' arm floor observed spread ratio'); - lines.push(' ' + '-'.repeat(78)); - for (const { arm, r } of results) { - const spread = r.observed - r.floor; - lines.push(` ${arm.label.padEnd(44)}${r.floor.toFixed(4)} ${r.observed.toFixed(4)}` + - ` ${spread >= 0 ? '+' : ''}${spread.toFixed(4)} ${(r.observed / r.floor).toFixed(3)}`); + const mean = (a) => a.reduce((x, y) => x + y, 0) / a.length; + const half = (a) => (Math.max(...a) - Math.min(...a)) / 2; + + lines.push(` arm floor observed spread (${repeats} runs)`); + lines.push(' ' + '-'.repeat(80)); + for (const { arm, runs } of results) { + const spreads = runs.map((x) => x.observed - x.floor); + lines.push(` ${arm.label.padEnd(44)}${mean(runs.map((x) => x.floor)).toFixed(4)}` + + ` ${mean(runs.map((x) => x.observed)).toFixed(4)}` + + ` ${mean(spreads) >= 0 ? '+' : ''}${mean(spreads).toFixed(4)} ±${half(spreads).toFixed(4)}`); } lines.push(''); - for (const { arm, r } of results) { + const gap = mean(results[0].runs.map((x) => x.observed - x.floor)) + - mean(results[1].runs.map((x) => x.observed - x.floor)); + const noise = Math.max( + half(results[0].runs.map((x) => x.observed - x.floor)), + half(results[1].runs.map((x) => x.observed - x.floor))); + lines.push(` stages minus legacy: ${gap >= 0 ? '+' : ''}${gap.toFixed(4)} against a noise band of ±${noise.toFixed(4)}`); + lines.push(` → ${Math.abs(gap) > noise * 2 ? 'a real difference' : 'INDISTINGUISHABLE at this sample size'}`); + lines.push(''); + + for (const { arm, runs, r } of results) { lines.push(` ${arm.label}`); - for (const [name, b] of Object.entries(r.byBlock)) { - lines.push(` ${name.padEnd(8)} between ${b.between.toFixed(3)}`); + for (const [name] of Object.entries(r.byBlock)) { + const v = runs.map((x) => x.byBlock[name].between); + lines.push(` ${name.padEnd(8)} between ${mean(v).toFixed(3)} ±${half(v).toFixed(3)}`); } - lines.push(` coupling ${r.coupling.toFixed(2)}`); - const worst = r.pairs[0]; - lines.push(` closest pair ${worst.a} ≈ ${worst.b} at ${worst.total.toFixed(3)}`); + const c = runs.map((x) => x.coupling); + lines.push(` coupling ${mean(c).toFixed(2)} ±${half(c).toFixed(2)}`); lines.push(''); } - const stages = results[0].r, legacy = results[1].r; - const ok = (stages.observed - stages.floor) > (legacy.observed - legacy.floor); - const headline = `stages spread ${(stages.observed - stages.floor).toFixed(4)} ` + - `vs legacy ${(legacy.observed - legacy.floor).toFixed(4)} — ` + - (ok ? 'the inversion helps' : 'no improvement'); + const ok = gap > noise * 2; + const headline = `stages ${gap >= 0 ? '+' : ''}${gap.toFixed(4)} vs legacy, noise ±${noise.toFixed(4)} — ` + + (Math.abs(gap) > noise * 2 ? (gap > 0 ? 'the inversion helps' : 'the inversion hurts') + : 'indistinguishable'); return { lines, ok, headline }; } diff --git a/flow-state/src/engine/shader-contract.js b/flow-state/src/engine/shader-contract.js index 1267d10..e0a701c 100644 --- a/flow-state/src/engine/shader-contract.js +++ b/flow-state/src/engine/shader-contract.js @@ -121,6 +121,13 @@ export const IDENTITY_UNIFORMS = { u_inkHatchScale: 'float', u_inkOutline: 'float', // 0..1 outline strength on top of the fill u_inkPosterize: 'float', // 0 = off, else levels + + u_latKind: 'float', // index into Identity.LATTICES + u_latJitter: 'float', // how far off the lattice things sit + u_latSpread: 'float', // how much of the frame it occupies + u_latScaleSpread: 'float', // 0 = all one size, 1 = a few large, many small + u_latScaleBias: 'float', // + puts the large ones in the middle + u_latScale: 'float', // the song's element size, ~0.1 tiny .. ~0.9 huge }; export const FRAME_UNIFORMS = [ @@ -350,6 +357,61 @@ float castChorus(vec2 q) { u_chorusNotchN, u_chorusNotchD, u_chorusHollow); } +// --- the staging ----------------------------------------------------------- +// Where things go. Shared, so two stages in one video agree about composition — +// and so the SIZE HIERARCHY is a decision the song makes once rather than one +// each stage makes for itself. The first four stages all placed similarly-sized +// elements, which left feature scale out of the measurement entirely. + +/** Node i of n on the song's lattice: xy position, z scale multiplier. */ +vec3 stageNode(float i, float n) { + vec2 h = hash22(vec2(i * 1.37 + 3.1, i * 0.71 + 7.7)); + float total = max(n, 1.0); + vec2 pos; + + if (u_latKind < 0.5) { // grid + float cols = max(1.0, floor(sqrt(total) + 0.5)); + float rows = max(1.0, ceil(total / cols)); + pos = vec2((mod(i, cols) / max(cols - 1.0, 1.0) - 0.5) * 2.0, + (floor(i / cols) / max(rows - 1.0, 1.0) - 0.5) * 2.0); + } else if (u_latKind < 1.5) { // radial rings + float rings = max(1.0, floor(sqrt(total * 0.5) + 0.5)); + float ring = mod(i, rings) + 1.0; + float a = (i / total) * 6.28318530718 * 3.0; + pos = vec2(cos(a), sin(a)) * (ring / rings); + } else if (u_latKind < 2.5) { // spiral, golden angle + float a = i * 2.39996323; + pos = vec2(cos(a), sin(a)) * sqrt(i / total); + } else if (u_latKind < 3.5) { // scatter + pos = (h - 0.5) * 2.0; + } else { // strata + float rows = max(1.0, floor(total / 4.0 + 0.5)); + pos = vec2((h.x - 0.5) * 2.0, + (mod(i, rows) / max(rows - 1.0, 1.0) - 0.5) * 2.0); + } + + pos += (h - 0.5) * u_latJitter; + pos *= u_latSpread; + // Sits on the same ground every other scene in the track sits on. + pos.y += sigHorizonY() * 0.3; + + // A power law when the song wants a hierarchy, near-uniform when it does + // not. Biased toward the middle or the edges. + float u = max(hash11(i * 7.13 + 1.7), 0.001); + float size = mix(1.0, pow(u, 1.0 + u_latScaleSpread * 2.5) * 2.4, u_latScaleSpread); + size *= 1.0 + u_latScaleBias * (0.5 - length(pos) * 0.5); + + // The song's own element size, relative to the neutral 0.35. A stage + // multiplies its own size param by this rather than choosing outright, so + // one song is made of a few huge forms and another of many small ones. + size *= u_latScale / 0.35; + + return vec3(pos, max(size, 0.05)); +} + +/** The song's element size as a multiplier a stage applies to its own size. */ +float stageScale() { return u_latScale / 0.35; } + // --- the ink --------------------------------------------------------------- // How the cast is drawn. Changes every pixel of every stage at once, and does // it structurally rather than chromatically — which is the point, since colour diff --git a/flow-state/src/look/Identity.js b/flow-state/src/look/Identity.js index d87d899..69b7dbc 100644 --- a/flow-state/src/look/Identity.js +++ b/flow-state/src/look/Identity.js @@ -29,6 +29,9 @@ /** Fill treatments, as the shader's `u_inkFill` index. */ export const FILLS = ['flat', 'ramp', 'hatch', 'stipple', 'halftone', 'hollow']; +/** Lattices, as the shader's `u_latKind` index. */ +export const LATTICES = ['grid', 'radial', 'spiral', 'scatter', 'strata']; + const clamp01 = (x) => Math.max(0, Math.min(1, x)); /** @@ -110,7 +113,47 @@ export function generateIdentity(summary, rng, sections = 4) { posterize: rng.bool(0.3) ? rng.int(3, 6) : 0, }; - return { cast: { protagonist, chorus }, ink, character: { angular, intricate, solid } }; + // STAGING: where things go, and how their sizes are distributed. + // + // The first four stages separated songs almost entirely on layout while + // their feature SCALE collapsed — every stage placed similarly-sized + // elements, so size stopped being a variable at all. A shared lattice fixes + // both halves: the stages agree with each other about placement, which is + // what makes a video look like itself, and the size hierarchy becomes a + // decision the song makes rather than one each stage makes for itself. + const lattice = { + kind: rng.pickWeighted(LATTICES, [ + 1 + angular * 3, // grid + 1 + (1 - angular) * 2, // radial + 1 + (1 - angular) * 2, // spiral + 2, // scatter + 1 + angular * 2, // strata + ]), + jitter: clamp01(rng.range(0, 0.5) + (1 - angular) * 0.3), + spread: rng.range(0.5, 0.85) + dynamic * 0.35, + // A few large and many small, or all one size. Busy material earns the + // hierarchy; a sparse track wants its elements to be equals. + scaleSpread: clamp01(0.15 + intricate * 0.6 + rng.range(-0.2, 0.25)), + // Whether the big ones sit in the middle or around the edges. + scaleBias: rng.range(-1, 1), + // How big the song's elements are AT ALL — a per-song decision rather + // than a per-stage one. + // + // This is the block the measurements kept pointing at. Sharing a cast + // lowered the floor as predicted, but `scale` — feature size — came out + // consistently WORSE than the legacy arm (0.033 against 0.053, well + // outside the noise), because every stage still chose its own element + // size from its own param range and every song therefore landed in the + // same place. A song made of six huge forms and a song made of four + // hundred tiny ones are different videos before anything else is + // decided; that decision belongs here. + elementScale: 0.35 * 2 ** rng.range(-1.4, 1.4) * (1.25 - intricate * 0.5), + }; + + return { + cast: { protagonist, chorus }, ink, lattice, + character: { angular, intricate, solid }, + }; } /** Neutral values, so a layer built without an identity renders as it always did. */ @@ -121,6 +164,8 @@ export const NEUTRAL_IDENTITY_UNIFORMS = { u_chorusNotchN: 0, u_chorusNotchD: 0, u_chorusHollow: 0, u_inkWeight: 0.3, u_inkEdge: 0.5, u_inkFill: 0, u_inkHatchAngle: 0, u_inkHatchScale: 80, u_inkOutline: 0, u_inkPosterize: 0, + u_latKind: 3, u_latJitter: 0.5, u_latSpread: 0.9, + u_latScaleSpread: 0.3, u_latScaleBias: 0, u_latScale: 0.35, }; /** @@ -162,6 +207,13 @@ export function identityUniforms(identity, shape = null) { u_inkFill: FILLS.indexOf(ink.fill), u_inkHatchAngle: ink.hatchAngle, u_inkHatchScale: ink.hatchScale, u_inkOutline: ink.outline, u_inkPosterize: ink.posterize, + + u_latKind: LATTICES.indexOf(identity.lattice.kind), + u_latJitter: identity.lattice.jitter, + u_latSpread: identity.lattice.spread, + u_latScaleSpread: identity.lattice.scaleSpread, + u_latScaleBias: identity.lattice.scaleBias, + u_latScale: identity.lattice.elementScale, }; } @@ -175,5 +227,7 @@ export function describeIdentity(identity) { const ink = identity.ink; return `cast ${form(a)} + ${form(b)} · ink ${ink.fill}` + `${ink.outline ? '+outline' : ''}${ink.posterize ? `/${ink.posterize}-tone` : ''}` + - ` w${ink.weight.toFixed(2)}`; + ` w${ink.weight.toFixed(2)} · on ${identity.lattice.kind}` + + ` at ${identity.lattice.elementScale < 0.2 ? 'tiny' : + identity.lattice.elementScale > 0.6 ? 'huge' : 'mid'} scale`; } diff --git a/flow-state/src/scenes/stage/constellation.js b/flow-state/src/scenes/stage/constellation.js index dda9673..172db64 100644 --- a/flow-state/src/scenes/stage/constellation.js +++ b/flow-state/src/scenes/stage/constellation.js @@ -8,7 +8,7 @@ export const constellation = { name: 'Constellation', family: 'minimal', kind: 'fragment', - consumes: ['cast', 'ink'], + consumes: ['cast', 'ink', 'staging'], texture: 0.6, traits: ['shape', 'camera', 'space', 'style'], @@ -42,13 +42,15 @@ vec4 scene(vec2 uv, vec2 p) { float fi = float(i); vec2 h = hash22(vec2(fi + u_seed, fi * 1.7)); - // A slow wander around a fixed home, so the constellation keeps its - // shape while nothing in it is ever quite still. - vec2 home = (h - 0.5) * 2.0 * u_spread; - vec2 pos = home + vec2(sin(t + h.x * 6.28), cos(t * 0.83 + h.y * 6.28)) * 0.08; + // Homes come from the song's lattice — the stage owns the wander, not + // the composition. A slow drift around a fixed home, so the + // constellation keeps its shape while nothing in it is ever quite still. + vec3 node = stageNode(fi, float(u_count)); + vec2 pos = node.xy * u_spread + + vec2(sin(t + h.x * 6.28), cos(t * 0.83 + h.y * 6.28)) * 0.08; float pulse = 0.7 + 0.3 * sin(t * 2.0 + fi) * u_twinkle; - float size = u_size * (0.5 + h.x) * pulse; + float size = u_size * node.z * pulse; vec2 q = (p - pos) / max(size, 1e-3); float d = castChorus(q) * size; diff --git a/flow-state/src/scenes/stage/procession.js b/flow-state/src/scenes/stage/procession.js index 53ded15..c138652 100644 --- a/flow-state/src/scenes/stage/procession.js +++ b/flow-state/src/scenes/stage/procession.js @@ -9,12 +9,12 @@ export const procession = { name: 'Procession', family: 'structural', kind: 'fragment', - consumes: ['cast', 'ink'], + consumes: ['cast', 'ink', 'staging'], texture: 0.4, traits: ['shape', 'camera', 'style'], params: { - columns: { type: 'int', range: [2, 9], default: 4, uniform: 'u_columns', bias: 'density' }, + columns: { type: 'int', range: [2, 8], default: 4, uniform: 'u_columns', bias: 'density' }, depth: { type: 'int', range: [2, 8], default: 4, uniform: 'u_depth', bias: 'density' }, march: { type: 'float', range: [0.05, 1.2],default: 0.3, uniform: 'u_march', bias: 'motion', rate: true }, size: { type: 'float', range: [0.1, 0.5], default: 0.28,uniform: 'u_size' }, @@ -39,30 +39,29 @@ vec4 scene(vec2 uv, vec2 p) { // Rows recede toward the horizon the track shares with every other scene. float horizon = sigHorizonY(); - for (int row = 0; row < 8; row++) { - if (row >= u_depth) break; - float fr = float(row); - // Rows further back are smaller and closer to the horizon. - float back = fr / max(float(u_depth), 1.0); - float scale = mix(1.0, 0.35, back * u_recede); - float y = mix(horizon - 0.9, horizon + 0.15, back); + // The formation is the song's lattice; the march is this stage's own idea + // of what to do with it. That split is the whole point — identity owns + // placement, the stage owns motion. + float total = min(float(u_columns * u_depth), 64.0); - for (int c = 0; c < 9; c++) { - if (c >= u_columns) break; - float fc = float(c); - float lane = (fc / max(float(u_columns) - 1.0, 1.0) - 0.5) * 2.4; + for (int i = 0; i < 64; i++) { + float fi = float(i); + if (fi >= total) break; - // The march: each row slides at its own pace, wrapping. - float phase = fract(t * (0.4 + back * 0.6) + fr * u_stagger + fc * 0.017); - float x = lane + (phase - 0.5) * 0.6; + vec3 node = stageNode(fi, total); + // Depth from the node's own height in the frame, so recession agrees + // with wherever the lattice put it rather than with a row index. + float back = clamp((node.y - (horizon - 0.9)) / 1.05, 0.0, 1.0); + float scale = mix(1.0, 0.35, back * u_recede) * node.z; - vec2 q = (p - vec2(x, y)) / max(u_size * scale, 1e-3); - q = rot(t * u_spin + fr) * q; + // The march: each element slides at a pace set by its depth, wrapping. + float phase = fract(t * (0.4 + back * 0.6) + fi * u_stagger * 0.13); + vec2 pos = node.xy + vec2((phase - 0.5) * 0.6, 0.0); - float d = castMain(q) * u_size * scale; - float a = inkMask(d, uv); - col = mix(col, pal(row + c + 2), a * (0.35 + 0.65 * (1.0 - back))); - } + vec2 q = rot(t * u_spin + fi) * (p - pos) / max(u_size * scale, 1e-3); + + float d = castMain(q) * u_size * scale; + col = mix(col, pal(i + 2), inkMask(d, uv) * (0.35 + 0.65 * (1.0 - back))); } col += sigGrain(uv); diff --git a/flow-state/src/scenes/stage/soloist.js b/flow-state/src/scenes/stage/soloist.js index 8f39395..bd26749 100644 --- a/flow-state/src/scenes/stage/soloist.js +++ b/flow-state/src/scenes/stage/soloist.js @@ -8,7 +8,7 @@ export const soloist = { name: 'Soloist', family: 'minimal', kind: 'fragment', - consumes: ['cast', 'ink'], + consumes: ['cast', 'ink', 'staging'], texture: 0.5, traits: ['shape', 'camera', 'style'], @@ -32,6 +32,9 @@ vec4 scene(vec2 uv, vec2 p) { p = sigCamera(p); p = sigFolded(p); float t = u_time * u_turn + u_seed; + // Even a close-up obeys the song's sense of scale — pulled toward it rather + // than replaced by it, since a soloist that shrinks to a dot stops being one. + float size0 = u_size * mix(1.0, stageScale(), 0.5); vec3 col = mix(pal(0) * 0.14, pal(1) * 0.18, length(p) * 0.5); @@ -39,17 +42,17 @@ vec4 scene(vec2 uv, vec2 p) { for (int i = 6; i >= 1; i--) { if (i > u_echoes) continue; float fi = float(i); - float size = u_size * (1.0 + fi * u_echoStep); + float size = size0 * (1.0 + fi * u_echoStep); vec2 q = rot(t * (1.0 - fi * 0.15)) * (p - u_offset) / max(size, 1e-3); float d = castMain(q) * size; float a = inkMask(d, uv) * (0.5 / fi); col = mix(col, pal(i + 1), a); } - vec2 q = rot(t) * (p - u_offset) / max(u_size, 1e-3); - float d = castMain(q) * u_size; + vec2 q = rot(t) * (p - u_offset) / max(size0, 1e-3); + float d = castMain(q) * size0; col = mix(col, pal(2), inkMask(d, uv)); - col += pal(3) * u_halo * (1.0 - smoothstep(0.0, u_size * 1.5, abs(d))) * 0.5; + col += pal(3) * u_halo * (1.0 - smoothstep(0.0, size0 * 1.5, abs(d))) * 0.5; col += sigGrain(uv); return vec4(inkValue(col), 1.0); diff --git a/flow-state/src/scenes/stage/swarm.js b/flow-state/src/scenes/stage/swarm.js index 3cf1066..ab98bd9 100644 --- a/flow-state/src/scenes/stage/swarm.js +++ b/flow-state/src/scenes/stage/swarm.js @@ -8,7 +8,7 @@ export const swarm = { name: 'Swarm', family: 'organic', kind: 'fragment', - consumes: ['cast', 'ink'], + consumes: ['cast', 'ink', 'staging'], texture: 0.5, traits: ['shape', 'camera', 'style'], @@ -37,18 +37,17 @@ vec4 scene(vec2 uv, vec2 p) { for (int i = 0; i < 48; i++) { if (i >= u_count) break; float fi = float(i); - vec2 h = hash22(vec2(fi + u_seed * 0.5, fi * 2.3)); - - // Each member rides the same flow field, which is what makes it a - // swarm rather than a scatter — cohesion decides how strictly. - vec2 seed = (h - 0.5) * 2.4; - vec2 flow = curl(seed * u_field + t * 0.15, t * 0.1); - vec2 pos = seed + flow * mix(0.05, 0.45, u_cohesion); + // Each member has a home on the song's lattice and rides the same flow + // field away from it, which is what makes it a swarm rather than a + // scatter — cohesion decides how far it is allowed to stray. + vec3 node = stageNode(fi, float(u_count)); + vec2 flow = curl(node.xy * u_field + t * 0.15, t * 0.1); + vec2 pos = node.xy + flow * mix(0.05, 0.45, u_cohesion); pos += vec2(sin(t * 0.7 + fi), cos(t * 0.6 + fi * 1.3)) * 0.06; - vec2 q = (p - pos) / max(u_size, 1e-3); + vec2 q = (p - pos) / max(u_size * node.z, 1e-3); q = rot(atan(flow.y, flow.x)) * q; // they face where they go - float d = castChorus(q) * u_size; + float d = castChorus(q) * u_size * node.z; col = mix(col, pal(i + 1), inkMask(d, uv)); col += pal(2) * u_trail * 0.004 / (0.02 + abs(d)); diff --git a/flow-state/tools/lint-scenes.js b/flow-state/tools/lint-scenes.js index a789cae..a3d899d 100644 --- a/flow-state/tools/lint-scenes.js +++ b/flow-state/tools/lint-scenes.js @@ -101,6 +101,24 @@ const TRAIT_EVIDENCE = { style: /\b(sigEdge|sigGrain|sigFolded|inkMask|inkValue|inkPattern)\s*\(|\bu_sig(Line|Soft|Texture|Fold)\b/, }; +// The shader preamble is a JS template literal, so a backtick anywhere inside +// it silently closes the literal. Twice now that has produced a check page that +// hangs on "starting…" with an empty console, which is an expensive way to find +// a typo. One grep is cheaper. +console.log('\nshader contract'); +{ + const src = readFileSync(join(SRC, 'engine/shader-contract.js'), 'utf8'); + const literal = src.slice(src.indexOf('export const PREAMBLE = `') + 25); + const body = literal.slice(0, literal.indexOf('\n`;')); + const stray = body.split('\n').filter((l) => l.includes('`') && !l.includes('${')); + if (stray.length) { + fail(`shader preamble contains a backtick, which closes the template literal:\n` + + stray.map((l) => ` ${l.trim()}`).join('\n')); + } else { + ok('preamble has no stray backticks'); + } +} + console.log('\nscene schema lint'); { const { scenes, FAMILIES } = await import(pathToFileURL(join(SRC, 'scenes/registry.js')).href);