diff --git a/flow-state/AGENTS.md b/flow-state/AGENTS.md new file mode 100644 index 0000000..a14dc08 --- /dev/null +++ b/flow-state/AGENTS.md @@ -0,0 +1,43 @@ +# AGENTS.md — flow-state + +## Design decisions + +All load-bearing decisions live in [`DESIGN.md`](./DESIGN.md). Read it before +changing behaviour. Changing a decision means updating `DESIGN.md` **and** +the gate that enforces it. `PLAN.md` is the full design; `EPIC-*.md` are the +epic designs. + +## Adding / fixing a scene + +Follow the `build-visualizer` skill (`.claude/skills/build-visualizer/`). +Short version: + +```bash +npm run new:scene -- "Name" --family= --traits=... +# ... write the scene() body ... +npm run lint:scenes +# open http://localhost:5180/checks.html?scene=Name +``` + +Verify in order: `lint:scenes` (~1s) → per-scene gate in `checks.html` → +`checks.html?slow=1` once before committing. Each rung is ~10× the cost of +the previous. + +## Rules that have bitten before (all in DESIGN.md §3–4) + +- Anything multiplying `u_time` must be `rate: true` — and rate params are + never modulated (drift/reactive/lookahead skip them). +- Discrete params (`int`/`bool`/`discrete: true`) hold per cue and are + never reactive: **actors and objects change only at camera cuts.** +- No contract-uniform reuse, no hardcoded colours (use `pal()`), base + image under every `prev()`, honour every declared trait, feed `sigCamera` + into the image. +- No `Math.random` / wall-clock in render-path code; motion is analytic + `f(t, seed)` so seek === playback and preview === export. + +## Checks + +```bash +npm test # audio pipeline + venue/model headless tests +npm run lint:scenes # determinism grep + schema/shader agreement +``` diff --git a/flow-state/DESIGN.md b/flow-state/DESIGN.md new file mode 100644 index 0000000..8f463fa --- /dev/null +++ b/flow-state/DESIGN.md @@ -0,0 +1,154 @@ +# flow-state — design decisions + +The decisions this project has taken, and why. New work must obey them; +changing one means updating this file and the gate that enforces it. + +Doc map: `PLAN.md` is the full design, `EPIC-2/3/4/5-3D.md` are the epic +designs, `HOWTO-visualizers.md` / `HOWTO-variety.md` are the scene-author +references, `MIGRATION.md` is the shader→stage migration recipe. + +## 1. Determinism spine + +- The audio is fully analysed **before the first frame renders**, into a + frame-indexed `FeatureTrack` table. Nothing reads a live `AnalyserNode`. + Preview maps `currentTime → frameIndex`; export counts frames. Same rows, + same image. +- No wall-clock (`performance.now`, `Date`) and no `Math.random` in + `engine/scenes/look/audio/params/actors/venues`. Seeded `Rng` only; + motion is analytic `f(t, seed)` — seek === playback, preview === export. +- `dt` is constant (`1/fps`); dropped preview frames hitch, never diverge. +- Feedback/ping-pong targets are explicitly cleared; shader programs are + primed (`Engine.prime`) before frame-exact rendering — unlinked programs + rendered the first ~10 frames wrong on the heaviest scene. +- Scene math lives in normalized aspect-corrected coords (`uv`, `p`); only + `u_pixelScale` carries pixels, so 720p preview matches 4K export. +- Guarantee is **per-machine** bit-identical; cross-machine is perceptual. + Same-machine allows max channel delta ≤ 1 (GPU load varies one LSB under + heavy shaders); real bugs score in the tens. +- Seeks need **warm-up** (`log(0.001)/log(decay)` frames) whenever feedback + is on. Section boundaries are NOT exact for free — the compositor's + feedback buffer is global and carries across them. Clearing it per + boundary would flash every transition; warm-up is the better trade. + +## 2. Lookahead + +Offline analysis is what buys anticipation: `buildSlope` ramps through the +bars before a higher-energy section, so visuals arrive at the drop already +at tension. A causal analyser can never do this. The outgoing layer of a +crossfade keeps its pre-boundary slope — feeding it the new section's +features collapses its ramp in one frame (a visible pop at the edit). + +## 3. Scenes: shader + schema, nothing else + +- A scene is a fragment shader (or model build/update) plus a params block. + Uniform binding, UI sliders, seeded sampling, arc drift and phase gates + are all derived from the schema. No per-scene wiring. +- Six families (`flow geometric organic structural minimal glitch`) chosen + by section kind — a breakdown never lands on a strobing glitch scene. +- Personality traits (`shape camera space style`) are a **contract**: each + track is built on 1–2 traits and a scene honouring fewer is never cast. + The library intentionally shrinks per track. Declared traits need + machine-checked evidence (lint grep + render-delta gate); a dead camera + (`sigCamera` assigned, result ignored) shipped once and is now linted. +- `rate: true` is mandatory for anything multiplying `u_time`. Phase is + `elapsed × rate`; modulating a rate jumps phase by `elapsed × Δrate` — + measured as 6 flashes/s, twice the WCAG 2.3.1 ceiling. +- Whole-frame luminance never pulses on the beat (3-flashes/s ceiling). + Pulse something local; quantise glitches to the bar grid. +- A scene reading `prev()` must paint a base image first (black first + frames, seek-fragile otherwise). `prev()` readers can never be grounds. +- No contract-uniform collisions, no hardcoded saturated colours — the + look generator recolours through `pal()`/`palRamp()` only. +- Loops need constant bounds with an early `break` on the deciding param + (`// lint: fixed-cost` opt-out for genuinely fixed work). + +## 4. Continuity: nothing disappears mid-shot + +Actors and objects change only at camera cuts: + +- **Discrete params** (`int`/`bool`, plus floats marked `discrete: true` + that drive loop bounds / instance counts) hold constant for the whole + cue. No drift, no lookahead ramp, no reactive modulation — those would + pop members in/out mid-shot. They still vary per section/variant sample, + so drops can be denser than intros; the change lands on a cut. +- A discrete param MAY declare `slowAxis`: the driver steps its travel on + the section-held journey instead of sliding it. +- Reactive on a discrete param and (unstated) drift of one are lint errors. +- Venue furniture never hard-toggles on interpolated state: chamber junk + and stage pylons fade (ride-crew pattern); ride roadside density is + arrangement-only. Re-hangs happen only on story jumps, always under a + camera cut (`venueJumpAt`); the venue layer itself is persistent and + never dissolved (old-room/new-room ghosting read as a glitch). + +## 5. Time structure: cues, cuts, camera + +- The arc driver works in **cues** (one per shot): a shot cut and a section + change take the same code path, differing only in transition length. +- Default transition is a slow dissolve (2 bars calm, 1 bar loud, capped + at 40% of the shot). Hard cuts only above the energy threshold — on calm + material a cut reads as a glitch. Even hard cuts are 2-frame ramps (a + 1-frame swap of bright scenes is a flash). +- Framing lives at the shot level (size constant per shot, gaze live); + the outgoing shot keeps travelling as it fades — freezing it stops the + old image dead before it disappears. +- Sharp transitions cut colour instantly with the image and always reframe + (a bigger reframe than a dissolve earns). +- Story variables are **staged, not ramped**: flat inside a section, + blended ~1 bar after boundaries. Moments (arrival/turn/climax/resolution) + are found in the audio; the story never overrides the song (bounded + bias terms). All scenes share one axis sign so the video travels one way. + +## 6. Colour and variety + +- Palettes in OKLCH; hue from **spectral tilt** (log treble/body ratio — + centroid and plain body-fraction both collapsed across masters); both + ways round the wheel; directors choose 1–4 palettes, blended in OKLCH + across dissolves. +- Per-track **temperament** (intensity/pace/detail/extremity) moves every + param dial so one scene cast in two videos isn't the same footage twice. +- **Overlays** on 5–55% of stacks, never `normal` blend, never above 0.6 + opacity — occasionally layered, never permanently cluttered. +- A scene varies when the SONG changes what's on screen: draw + `castMain`/`castChorus` (`form` in 3D), size via `stageScale()`, place + via `stageNode()`. No effect-knobs (glow/grain/chroma belong to post). +- Grain is a per-track **treatment** (mode/cell/rate/mask/chroma, ~45% of + tracks get none); scene `texture: 0..2` scales only its share. +- Absolute animation speed follows the song: motion bias is + tempo-dominated and every `rate: true` param scales by per-track + `rateScale` — slow songs got fast scenes before this. + +## 7. Grounds and composition + +- `surface` is measured (`metadata.json`), never declared — nine scenes + claimed `canvas` while painting under a third of the frame. +- A ground fills the frame on average and never vanishes under any + identity (worst case counts, not the mean); hollow grounds are + flattened via `groundPersonalityFrom`. +- Model layers share one `Compositor` perspective/depth rig; shader depth + stays faked. Venue rooms are furnished with the track's monolith body + so the room reads as this track's place. + +## 8. 3D on the same terms + +- `ActorSpec` is data, not scene graph (serialisable, hashable); geometry + via `actorToGeometry` from the same `Identity.form` the shaders march. +- Model motion is analytic, instanced, seeded; kit deformation reuses the + song's silhouette params; `castSolid` impostors remain the fallback. +- New actors are a file plus a registry entry, gated like scenes. + +## 9. Export and review + +- WebCodecs primary, frame-dump+ffmpeg fallback; title card, edge fades, + progress/cancel; A/V sync within one frame; survives a real upload test. +- Preview is the primary surface: transport, section jump/loop, live param + edit, timeline strip, re-roll + locks, draft/full quality, 20s + segment test-renders. Never commit to a full render blind. +- Every phase gate is measured; the battery (6 tracks ambient→hard EDM) + is watched end to end — monotony is invisible to automation. + +## 10. History + +- Forked from `party-stage`, then detached: zero cross-imports. +- Corrections kept: boundary seeks need warm-up (not exact for free); + `layout` is blind on full-frame fields; aggregate ratios (e.g. + `separation`) swing run-to-run — steer by direct render comparisons. diff --git a/flow-state/src/checks/phase11.js b/flow-state/src/checks/phase11.js index 0c79a20..aa1665e 100644 --- a/flow-state/src/checks/phase11.js +++ b/flow-state/src/checks/phase11.js @@ -21,7 +21,7 @@ import { shiftPalette, paletteContrast } from '../look/palette.js'; import { ArcDriver } from '../look/ArcDriver.js'; import { Engine } from '../engine/Engine.js'; import { featureProviderFor } from '../audio/FeatureTrack.js'; -import { sampleValues, clampValue, canBackground } from '../params/schema.js'; +import { sampleValues, clampValue, canBackground, isDiscreteParam } from '../params/schema.js'; import { Rng } from '../engine/rng.js'; import { frameDistance, frameLuminance } from '../engine/hash.js'; import { SHOT_SIZES, SHOT_SIZE_NAMES, describeFraming } from '../look/framing.js'; @@ -505,6 +505,23 @@ check(11, 'the slow axis is a journey rather than a cycle', () => { const end = at(t.duration * 0.95); for (const item of axis) { + // Discrete params step at cuts instead of travelling: held within + // the shot by construction (see schema.js isDiscreteParam), so the + // travel assertion below cannot apply. What is asserted instead is + // the hold itself — two samples inside one cue must agree exactly — + // while the travel magnitude is already covered structurally above. + if (isDiscreteParam(item.def)) { + const t0 = (cue.startFrame + 5) / 60; + const t1 = (cue.endFrame - 5) / 60; + if (t1 > t0) { + const va = at(t0)[item.name], vb = at(t1)[item.name]; + if (va !== vb) { + problems.push(`${spec.module.name}.${item.name}: ${va} → ${vb} ` + + `mid-shot — discrete params change only at cuts`); + } + } + continue; + } const [lo, hi] = item.def.range; const span = hi - lo; const a = start[item.name], m = mid[item.name], z = end[item.name]; diff --git a/flow-state/src/engine/Layer.js b/flow-state/src/engine/Layer.js index af17ba5..58e860e 100644 --- a/flow-state/src/engine/Layer.js +++ b/flow-state/src/engine/Layer.js @@ -4,7 +4,7 @@ import { SIGNATURE_UNIFORMS, IDENTITY_UNIFORMS, IDENTITY_ARRAY_UNIFORMS, } from './shader-contract.js'; import { signatureUniforms, NEUTRAL_UNIFORMS } from '../look/Personality.js'; -import { clampValue } from '../params/schema.js'; +import { clampValue, isDiscreteParam } from '../params/schema.js'; import { surfaceOf } from '../scenes/surface.js'; import { CANVAS_BEAT_CAP, @@ -208,6 +208,9 @@ export class Layer { // Rate params multiply absolute time; modulating them jumps the phase // by elapsed * delta. See params/schema.js RATE_FLAG. if (def.rate) continue; + // Continuity rule (see params/schema.js isDiscreteParam): a reactive + // count pops members on every beat. Discrete params hold per cue. + if (isDiscreteParam(def)) continue; const raw = features[r.feature]; if (raw === undefined) continue; diff --git a/flow-state/src/look/ArcDriver.js b/flow-state/src/look/ArcDriver.js index 9e7dadc..317eeeb 100644 --- a/flow-state/src/look/ArcDriver.js +++ b/flow-state/src/look/ArcDriver.js @@ -1,12 +1,12 @@ import { createLayer } from '../engine/Layer.js'; import { Rng } from '../engine/rng.js'; -import { clampValue } from '../params/schema.js'; +import { clampValue, isDiscreteParam } from '../params/schema.js'; import { paletteShiftAt } from './paletteArc.js'; import { shiftPalette, lerpPalettes } from './palette.js'; import { frameShot, neutralFraming } from './framing.js'; import { planGaze, gazeAt } from './Camera.js'; import { planVenueShots, venueShotAt, describeVenueShot } from './venueShots.js'; -import { storyStateAt, NEUTRAL_STATE } from './Story.js'; +import { storyStateAt, storyForSection, NEUTRAL_STATE } from './Story.js'; import { subjectIndexOf, isGround } from './stack.js'; import { groundPersonalityFrom } from '../scenes/surface.js'; import { derivePalettePlan, directorByName } from './directors.js'; @@ -383,6 +383,10 @@ export class ArcDriver { for (const [name, def] of Object.entries(spec.module.params || {})) { if (def.type === 'palette' || def.type === 'bool' || def.fixed) continue; if (def.noDrift || def.rate) continue; // see schema.js RATE_FLAG + // Continuity rule (see schema.js isDiscreteParam): a drifting count + // pops members mid-shot. Discrete params hold for the whole cue and + // change only at cuts, via the per-section/variant sample. + if (isDiscreteParam(def)) continue; const [lo, hi] = def.range || [0, 1]; plan.push({ name, @@ -517,10 +521,28 @@ export class ArcDriver { const p = Math.max(0, Math.min(1, time / duration)); journey = p * p * (3 - 2 * p); } + // Discrete params step their slow-axis travel at cuts instead of + // sliding it: the section-held journey moves only at section + // boundaries (which are always cuts), and without a story the cue's + // own start frame is the step. Either way nothing pops mid-shot. + // See params/schema.js isDiscreteParam. + let journeyStepped = null; + const steppedJourney = () => { + if (journeyStepped !== null) return journeyStepped; + if (this.look.story) { + journeyStepped = storyForSection(this.look.story, cue.sectionIndex).journey; + } else { + const duration = Math.max(1e-6, this.track.duration); + const pc = Math.max(0, Math.min(1, (cue.startFrame / this.track.fps) / duration)); + journeyStepped = pc * pc * (3 - 2 * pc); + } + return journeyStepped; + }; for (const item of this._slowAxisFor(spec.module)) { const base = out[item.name]; if (typeof base !== 'number') continue; - out[item.name] = clampValue(item.def, base + item.travel * (journey - 0.5)); + const j = isDiscreteParam(item.def) ? steppedJourney() : journey; + out[item.name] = clampValue(item.def, base + item.travel * (j - 0.5)); } for (const item of this._driftPlan(cue.sectionIndex, cue.variant, slot)) { @@ -545,6 +567,7 @@ export class ArcDriver { const target = next.params; for (const [name, def] of Object.entries(spec.module.params || {})) { if (def.type === 'palette' || typeof out[name] !== 'number') continue; + if (isDiscreteParam(def)) continue; if (typeof target[name] !== 'number') continue; out[name] = clampValue(def, out[name] + (target[name] - out[name]) * slope); } @@ -553,6 +576,7 @@ export class ArcDriver { // of their range so the build still reads as a build. for (const [name, def] of Object.entries(spec.module.params || {})) { if (typeof out[name] !== 'number') continue; + if (isDiscreteParam(def)) continue; if (def.bias !== 'energy' && def.bias !== 'density') continue; const hi = (def.range || [0, 1])[1]; out[name] = clampValue(def, out[name] + (hi - out[name]) * slope * 0.5); diff --git a/flow-state/src/params/schema.js b/flow-state/src/params/schema.js index b1a4e49..c19a4ea 100644 --- a/flow-state/src/params/schema.js +++ b/flow-state/src/params/schema.js @@ -42,6 +42,28 @@ export const RATE_FLAG = 'rate'; // all. export const SLOW_AXIS_FLAG = 'slowAxis'; +/** + * CONTINUITY RULE: actors and objects can't just disappear during a scene, + * only during camera cuts. + * + * A discrete param is one whose change adds or removes things rather than + * moving them: every `int`/`bool`, plus any float used as a loop bound / + * instance count and marked `discrete: true` (e.g. a float `count` driving + * `if (float(i) >= u_count) break`). Ramping one mid-shot pops members in + * and out one frame to the next; holding it per cue moves the pop under the + * cut, where the venue arrangement salt already lives (see ArcDriver). + * + * Discrete params are therefore held constant for the whole cue: no drift, + * no slow-axis travel, no lookahead ramp, no reactive modulation. They still + * vary per section/variant sample, so a track's drops can be denser than its + * intros — the change just lands on a cut. + */ +export function isDiscreteParam(def) { + if (!def) return false; + if (def.type === 'int' || def.type === 'bool') return true; + return def.discrete === true; +} + /** Valid feature names a `reactive` entry may reference. Lint enforces this. */ export const REACTIVE_FEATURES = [ 'loudness', 'rms', @@ -364,6 +386,10 @@ export function validateModule(module) { if (def.type === 'palette' || def.type === 'bool' || def.fixed) { errors.push(`${where}: \`slowAxis\` needs a numeric range to walk`); } + // Discrete params MAY declare slowAxis: the arc driver steps the + // travel at cuts (section-held journey) instead of sliding it, so + // the count still journeys across the video without popping + // mid-shot. See ArcDriver._paramsAt. } } @@ -382,6 +408,10 @@ export function validateModule(module) { errors.push(`${where}: '${name}' is a rate param and cannot be reactive — ` + `modulating it jumps phase by elapsed*delta (see RATE_FLAG)`); } + if (params[name] && isDiscreteParam(params[name])) { + errors.push(`${where}: '${name}' is discrete and cannot be reactive — ` + + `modulating it pops actors mid-shot; counts change only at cuts`); + } } return errors; diff --git a/flow-state/src/scenes/shader/firefly-drift.js b/flow-state/src/scenes/shader/firefly-drift.js index 9175806..561525d 100644 --- a/flow-state/src/scenes/shader/firefly-drift.js +++ b/flow-state/src/scenes/shader/firefly-drift.js @@ -14,7 +14,7 @@ export const fireflyDrift = { traits: ['camera', 'style'], params: { - count: { type: 'float', range: [6, 48], default: 22, uniform: 'u_count', bias: 'density' }, + count: { type: 'float', range: [6, 48], default: 22, uniform: 'u_count', bias: 'density', discrete: true }, speed: { type: 'float', range: [0.05, 0.8], default: 0.3, uniform: 'u_speed', bias: 'motion', rate: true }, jitter: { type: 'float', range: [0, 0.4], default: 0.12, uniform: 'u_jitter' }, spread: { type: 'float', range: [0.4, 1.3], default: 0.95, uniform: 'u_spread' }, diff --git a/flow-state/src/scenes/shader/flora.js b/flow-state/src/scenes/shader/flora.js index b28191a..f69426f 100644 --- a/flow-state/src/scenes/shader/flora.js +++ b/flow-state/src/scenes/shader/flora.js @@ -20,7 +20,7 @@ export const flora = { // so worst case was 5 stalks x (12 stem + 6 petals + 6 blooms) = 120 // SDF evals per pixel — measured ~12x its neighbours. Trimmed to // 4 x (8 + 4 + 4) = 64: same silhouette, half the cost. - count: { type: 'float', range: [1, 4], default: 3, uniform: 'u_count', bias: 'density' }, + count: { type: 'float', range: [1, 4], default: 3, uniform: 'u_count', bias: 'density', discrete: true }, height: { type: 'float', range: [0.6, 2.0], default: 1.1, uniform: 'u_height', bias: 'energy' }, stem: { type: 'float', range: [0.008, 0.05], default: 0.02, uniform: 'u_stem' }, sway: { type: 'float', range: [0.05, 0.5], default: 0.2, uniform: 'u_sway', bias: 'motion' }, diff --git a/flow-state/src/scenes/shader/gate-corridor.js b/flow-state/src/scenes/shader/gate-corridor.js index 0724f1d..dc2e32f 100644 --- a/flow-state/src/scenes/shader/gate-corridor.js +++ b/flow-state/src/scenes/shader/gate-corridor.js @@ -20,7 +20,7 @@ export const gateCorridor = { traits: ['shape', 'camera', 'space', 'style'], params: { - gates: { type: 'int', range: [3, 14], default: 8, uniform: 'u_gates', bias: 'density' , slowAxis: true }, + gates: { type: 'int', range: [3, 14], default: 8, uniform: 'u_gates', bias: 'density', slowAxis: true }, aperture: { type: 'float', range: [0.15, 0.9], default: 0.45, uniform: 'u_aperture' }, thickness:{ type: 'float', range: [0.02, 0.3], default: 0.09, uniform: 'u_thickness' }, travel: { type: 'float', range: [0.02, 0.7], default: 0.2, uniform: 'u_travel', bias: 'motion', rate: true }, diff --git a/flow-state/src/scenes/shader/horizon-lines.js b/flow-state/src/scenes/shader/horizon-lines.js index d2cc0a2..b164906 100644 --- a/flow-state/src/scenes/shader/horizon-lines.js +++ b/flow-state/src/scenes/shader/horizon-lines.js @@ -13,7 +13,7 @@ export const horizonLines = { traits: ['space', 'camera', 'style'], params: { - count: { type: 'float', range: [3, 40], default: 14, uniform: 'u_count', bias: 'density' }, + count: { type: 'float', range: [3, 40], default: 14, uniform: 'u_count', bias: 'density', discrete: true }, thickness: { type: 'float', range: [0.002, 0.03], default: 0.008, uniform: 'u_thickness' }, bend: { type: 'float', range: [0, 1.2], default: 0.35, uniform: 'u_bend' }, speed: { type: 'float', range: [0.02, 0.4], default: 0.12, uniform: 'u_speed', bias: 'motion', rate: true }, diff --git a/flow-state/src/scenes/shader/stairwell-descent.js b/flow-state/src/scenes/shader/stairwell-descent.js index c6fbbef..ac1afc9 100644 --- a/flow-state/src/scenes/shader/stairwell-descent.js +++ b/flow-state/src/scenes/shader/stairwell-descent.js @@ -31,7 +31,6 @@ export const stairwellDescent = { reactive: { lit: { feature: 'bandHigh', amount: 0.3, response: 'smooth' }, - rails: { feature: 'bandMid', amount: 0.15, response: 'smooth' }, }, shader: ` diff --git a/flow-state/src/scenes/stage/pylon-field-3d.js b/flow-state/src/scenes/stage/pylon-field-3d.js index 002777e..8cf308f 100644 --- a/flow-state/src/scenes/stage/pylon-field-3d.js +++ b/flow-state/src/scenes/stage/pylon-field-3d.js @@ -23,8 +23,8 @@ export const pylonField3D = { texture: 0.4, params: { - columns: { type: 'float', range: [3, 16], default: 8, uniform: 'u_columns', bias: 'density', slowAxis: true }, - rows: { type: 'float', range: [2, 10], default: 6, uniform: 'u_rows', bias: 'density' }, + columns: { type: 'float', range: [3, 16], default: 8, uniform: 'u_columns', bias: 'density', slowAxis: true, discrete: true }, + rows: { type: 'float', range: [2, 10], default: 6, uniform: 'u_rows', bias: 'density', discrete: true }, spread: { type: 'float', range: [0.6, 1.6], default: 1.1, uniform: 'u_spread' }, height: { type: 'float', range: [0.3, 1.4], default: 0.85, uniform: 'u_height', bias: 'energy' }, pulse: { type: 'float', range: [0, 1.2], default: 0.5, uniform: 'u_pulse', bias: 'energy' }, @@ -34,7 +34,6 @@ export const pylonField3D = { reactive: { pulse: { feature: 'beat', amount: 0.4, response: 'smooth' }, - rows: { feature: 'bandLow', amount: 0.25 }, }, build({ scene, seed, params, actorSpec, THREE }) { @@ -84,7 +83,6 @@ export const pylonField3D = { const t = timeline.time; const beat = features.beat || 0; const beatPhase = features.beatPhase || 0; - const bandLow = features.bandLow || 0; const identity = personality ? personality.identity : null; const pal = palette && palette.length ? palette : [[0.9, 0.9, 0.92], [0.7, 0.6, 0.8], [0.4, 0.6, 0.9], [0.9, 0.5, 0.4]]; @@ -129,7 +127,7 @@ export const pylonField3D = { scene.fog.color.setRGB(c[0] * (0.08 + wash * 0.15), c[1] * (0.08 + wash * 0.15), c[2] * (0.12 + wash * 0.18)); } - const cols = Math.max(3, Math.min(16, Math.round(params.columns + bandLow * 0.1))); + const cols = Math.max(3, Math.min(16, Math.round(params.columns))); const rows = Math.max(2, Math.min(10, Math.round(params.rows))); const spread = Math.max(0.4, params.spread) * (0.7 + latticeSpread * 0.6) * (latticeKind === 'radial' ? 1.15 : latticeKind === 'scatter' ? 0.92 : 1); const height = Math.max(0.2, params.height); diff --git a/flow-state/src/venues/chamber.js b/flow-state/src/venues/chamber.js index 30875c0..7fd2799 100644 --- a/flow-state/src/venues/chamber.js +++ b/flow-state/src/venues/chamber.js @@ -727,18 +727,20 @@ export const chamber = { const idx = g.userData.index || 0; const isActor = g.userData.kind === 'actor'; // Actors are the subject: always dressed. Junk thins out in - // sparse setups via popMul, as before. - if (isActor) { - g.visible = opacity > 0.01; - } else { - const popMul = vState ? vState.popMul : 1; - const showIndex = idx / Math.max(1, instance.popCount); - g.visible = opacity > 0.01 && (showIndex < (0.35 + popMul * 0.75)); - } + // sparse setups via popMul — as a SMOOTH opacity fade, never a + // visible toggle: the interpolated venueState crosses thresholds + // at arbitrary mid-section moments, and hard cuts there read as + // random flashes (same pattern as the ride's crew). Actors take + // the first indices so the fade never touches them. + const popMul = vState ? vState.popMul : 1; + const showIndex = idx / Math.max(1, instance.popCount); + const fade = isActor ? 1 + : Math.max(0, Math.min(1, ((0.35 + popMul * 0.75) - showIndex) / 0.12)); + g.visible = opacity > 0.01 && fade > 0.01; g.traverse((mesh) => { if (mesh.isMesh) { - mesh.material.opacity = opacity; - mesh.material.transparent = opacity < 0.999; + mesh.material.opacity = opacity * fade; + mesh.material.transparent = opacity * fade < 0.999; // The room's population breathes with the story arc: dim in // setup, bright at the climax. Pure f(venueState), no clock. // Figures breathe brighter — they must read against their diff --git a/flow-state/src/venues/ride.js b/flow-state/src/venues/ride.js index 5238898..89eed3c 100644 --- a/flow-state/src/venues/ride.js +++ b/flow-state/src/venues/ride.js @@ -764,7 +764,7 @@ export const ride = { // whole town; light/fog still arc within each. if (instance.roadside) { const n = instance.roadside.children.length; - const dense = (arrangement % 2 === 1) || (vState && vState.popMul > 0.8); + const dense = (arrangement % 2 === 1); const showUpTo = dense ? n : Math.max(3, Math.floor(n * 0.45)); // A billboard shot with a hidden billboard is an empty road: the // board's own slot is always on while it is the subject. diff --git a/flow-state/src/venues/stage.js b/flow-state/src/venues/stage.js index 9c2241d..65848a7 100644 --- a/flow-state/src/venues/stage.js +++ b/flow-state/src/venues/stage.js @@ -301,20 +301,27 @@ export const stage = { for (const pylon of instance.pylonContainer.children) { const crown = pylon.userData.crown; const rung = pylon.userData.rung; + // Far rows thin out in sparse setups via popMul — as a SMOOTH + // opacity fade, never a visible toggle: the interpolated + // venueState crosses thresholds at arbitrary mid-section moments + // (same pattern as the ride's crew and the chamber's junk). + const popMul = vState ? vState.popMul : 1; + const fi = pylon.userData.f || 0; // far rows more sensitive + const fade = Math.max(0, Math.min(1, ((0.35 + popMul * 0.75) - fi) / 0.12)); + pylon.visible = opacity > 0.01 && fade > 0.01; if (crown) { const yaw = t*0.35 + crown.userData.yawSeed + shapeTilt*0.2; crown.rotation.order='YXZ'; crown.rotation.set(0.12+Math.sin(t*0.28 + pylon.userData.f)*0.14, yaw, Math.sin(t*0.16)*0.08); - for (const m of crown.children) if(m.isMesh){ m.material.opacity=opacity; m.material.transparent=opacity<0.999; m.visible=opacity>0.01; } + for (const m of crown.children) if(m.isMesh){ m.material.opacity=opacity*fade; m.material.transparent=opacity*fade<0.999; m.visible=fade>0.01; } } if (rung) { rung.rotation.order='YXZ'; rung.rotation.set(0, t*0.22 + rung.userData.yawSeed, 0); - rung.material.opacity=opacity*0.95; - rung.visible=opacity>0.01; + rung.material.opacity=opacity*0.95*fade; + rung.material.transparent=opacity*fade<0.999; + rung.visible=fade>0.01; } - { const popMul = vState ? vState.popMul : 1; const fi = pylon.userData.f || 0; // far rows more sensitive - pylon.visible = opacity>0.01 && (fi < (0.35 + popMul*0.75)); } } // Field drift instance.pylonContainer.position.x = Math.sin(t*camSwayRate)*camSway*0.5;