Continuity rule: discrete params hold per cue; add DESIGN.md + AGENTS.md
Actors and objects change only at camera cuts. int/bool/discrete params skip drift, lookahead and reactivity; discrete slowAxis steps on the section-held journey. Venues fade instead of toggling on interpolated popMul; roadside density is arrangement-only. DESIGN.md collects the project's design decisions; AGENTS.md points agents at it and the build-visualizer loop.
This commit is contained in:
@@ -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=<flow|organic|minimal|structural|geometric|glitch> --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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
@@ -21,7 +21,7 @@ import { shiftPalette, paletteContrast } from '../look/palette.js';
|
|||||||
import { ArcDriver } from '../look/ArcDriver.js';
|
import { ArcDriver } from '../look/ArcDriver.js';
|
||||||
import { Engine } from '../engine/Engine.js';
|
import { Engine } from '../engine/Engine.js';
|
||||||
import { featureProviderFor } from '../audio/FeatureTrack.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 { Rng } from '../engine/rng.js';
|
||||||
import { frameDistance, frameLuminance } from '../engine/hash.js';
|
import { frameDistance, frameLuminance } from '../engine/hash.js';
|
||||||
import { SHOT_SIZES, SHOT_SIZE_NAMES, describeFraming } from '../look/framing.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);
|
const end = at(t.duration * 0.95);
|
||||||
|
|
||||||
for (const item of axis) {
|
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 [lo, hi] = item.def.range;
|
||||||
const span = hi - lo;
|
const span = hi - lo;
|
||||||
const a = start[item.name], m = mid[item.name], z = end[item.name];
|
const a = start[item.name], m = mid[item.name], z = end[item.name];
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
SIGNATURE_UNIFORMS, IDENTITY_UNIFORMS, IDENTITY_ARRAY_UNIFORMS,
|
SIGNATURE_UNIFORMS, IDENTITY_UNIFORMS, IDENTITY_ARRAY_UNIFORMS,
|
||||||
} from './shader-contract.js';
|
} from './shader-contract.js';
|
||||||
import { signatureUniforms, NEUTRAL_UNIFORMS } from '../look/Personality.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 { surfaceOf } from '../scenes/surface.js';
|
||||||
import {
|
import {
|
||||||
CANVAS_BEAT_CAP,
|
CANVAS_BEAT_CAP,
|
||||||
@@ -208,6 +208,9 @@ export class Layer {
|
|||||||
// Rate params multiply absolute time; modulating them jumps the phase
|
// Rate params multiply absolute time; modulating them jumps the phase
|
||||||
// by elapsed * delta. See params/schema.js RATE_FLAG.
|
// by elapsed * delta. See params/schema.js RATE_FLAG.
|
||||||
if (def.rate) continue;
|
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];
|
const raw = features[r.feature];
|
||||||
if (raw === undefined) continue;
|
if (raw === undefined) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { createLayer } from '../engine/Layer.js';
|
import { createLayer } from '../engine/Layer.js';
|
||||||
import { Rng } from '../engine/rng.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 { paletteShiftAt } from './paletteArc.js';
|
||||||
import { shiftPalette, lerpPalettes } from './palette.js';
|
import { shiftPalette, lerpPalettes } from './palette.js';
|
||||||
import { frameShot, neutralFraming } from './framing.js';
|
import { frameShot, neutralFraming } from './framing.js';
|
||||||
import { planGaze, gazeAt } from './Camera.js';
|
import { planGaze, gazeAt } from './Camera.js';
|
||||||
import { planVenueShots, venueShotAt, describeVenueShot } from './venueShots.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 { subjectIndexOf, isGround } from './stack.js';
|
||||||
import { groundPersonalityFrom } from '../scenes/surface.js';
|
import { groundPersonalityFrom } from '../scenes/surface.js';
|
||||||
import { derivePalettePlan, directorByName } from './directors.js';
|
import { derivePalettePlan, directorByName } from './directors.js';
|
||||||
@@ -383,6 +383,10 @@ export class ArcDriver {
|
|||||||
for (const [name, def] of Object.entries(spec.module.params || {})) {
|
for (const [name, def] of Object.entries(spec.module.params || {})) {
|
||||||
if (def.type === 'palette' || def.type === 'bool' || def.fixed) continue;
|
if (def.type === 'palette' || def.type === 'bool' || def.fixed) continue;
|
||||||
if (def.noDrift || def.rate) continue; // see schema.js RATE_FLAG
|
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];
|
const [lo, hi] = def.range || [0, 1];
|
||||||
plan.push({
|
plan.push({
|
||||||
name,
|
name,
|
||||||
@@ -517,10 +521,28 @@ export class ArcDriver {
|
|||||||
const p = Math.max(0, Math.min(1, time / duration));
|
const p = Math.max(0, Math.min(1, time / duration));
|
||||||
journey = p * p * (3 - 2 * p);
|
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)) {
|
for (const item of this._slowAxisFor(spec.module)) {
|
||||||
const base = out[item.name];
|
const base = out[item.name];
|
||||||
if (typeof base !== 'number') continue;
|
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)) {
|
for (const item of this._driftPlan(cue.sectionIndex, cue.variant, slot)) {
|
||||||
@@ -545,6 +567,7 @@ export class ArcDriver {
|
|||||||
const target = next.params;
|
const target = next.params;
|
||||||
for (const [name, def] of Object.entries(spec.module.params || {})) {
|
for (const [name, def] of Object.entries(spec.module.params || {})) {
|
||||||
if (def.type === 'palette' || typeof out[name] !== 'number') continue;
|
if (def.type === 'palette' || typeof out[name] !== 'number') continue;
|
||||||
|
if (isDiscreteParam(def)) continue;
|
||||||
if (typeof target[name] !== 'number') continue;
|
if (typeof target[name] !== 'number') continue;
|
||||||
out[name] = clampValue(def, out[name] + (target[name] - out[name]) * slope);
|
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.
|
// of their range so the build still reads as a build.
|
||||||
for (const [name, def] of Object.entries(spec.module.params || {})) {
|
for (const [name, def] of Object.entries(spec.module.params || {})) {
|
||||||
if (typeof out[name] !== 'number') continue;
|
if (typeof out[name] !== 'number') continue;
|
||||||
|
if (isDiscreteParam(def)) continue;
|
||||||
if (def.bias !== 'energy' && def.bias !== 'density') continue;
|
if (def.bias !== 'energy' && def.bias !== 'density') continue;
|
||||||
const hi = (def.range || [0, 1])[1];
|
const hi = (def.range || [0, 1])[1];
|
||||||
out[name] = clampValue(def, out[name] + (hi - out[name]) * slope * 0.5);
|
out[name] = clampValue(def, out[name] + (hi - out[name]) * slope * 0.5);
|
||||||
|
|||||||
@@ -42,6 +42,28 @@ export const RATE_FLAG = 'rate';
|
|||||||
// all.
|
// all.
|
||||||
export const SLOW_AXIS_FLAG = 'slowAxis';
|
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. */
|
/** Valid feature names a `reactive` entry may reference. Lint enforces this. */
|
||||||
export const REACTIVE_FEATURES = [
|
export const REACTIVE_FEATURES = [
|
||||||
'loudness', 'rms',
|
'loudness', 'rms',
|
||||||
@@ -364,6 +386,10 @@ export function validateModule(module) {
|
|||||||
if (def.type === 'palette' || def.type === 'bool' || def.fixed) {
|
if (def.type === 'palette' || def.type === 'bool' || def.fixed) {
|
||||||
errors.push(`${where}: \`slowAxis\` needs a numeric range to walk`);
|
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 — ` +
|
errors.push(`${where}: '${name}' is a rate param and cannot be reactive — ` +
|
||||||
`modulating it jumps phase by elapsed*delta (see RATE_FLAG)`);
|
`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;
|
return errors;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const fireflyDrift = {
|
|||||||
traits: ['camera', 'style'],
|
traits: ['camera', 'style'],
|
||||||
|
|
||||||
params: {
|
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 },
|
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' },
|
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' },
|
spread: { type: 'float', range: [0.4, 1.3], default: 0.95, uniform: 'u_spread' },
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const flora = {
|
|||||||
// so worst case was 5 stalks x (12 stem + 6 petals + 6 blooms) = 120
|
// so worst case was 5 stalks x (12 stem + 6 petals + 6 blooms) = 120
|
||||||
// SDF evals per pixel — measured ~12x its neighbours. Trimmed to
|
// SDF evals per pixel — measured ~12x its neighbours. Trimmed to
|
||||||
// 4 x (8 + 4 + 4) = 64: same silhouette, half the cost.
|
// 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' },
|
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' },
|
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' },
|
sway: { type: 'float', range: [0.05, 0.5], default: 0.2, uniform: 'u_sway', bias: 'motion' },
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export const horizonLines = {
|
|||||||
traits: ['space', 'camera', 'style'],
|
traits: ['space', 'camera', 'style'],
|
||||||
|
|
||||||
params: {
|
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' },
|
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' },
|
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 },
|
speed: { type: 'float', range: [0.02, 0.4], default: 0.12, uniform: 'u_speed', bias: 'motion', rate: true },
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ export const stairwellDescent = {
|
|||||||
|
|
||||||
reactive: {
|
reactive: {
|
||||||
lit: { feature: 'bandHigh', amount: 0.3, response: 'smooth' },
|
lit: { feature: 'bandHigh', amount: 0.3, response: 'smooth' },
|
||||||
rails: { feature: 'bandMid', amount: 0.15, response: 'smooth' },
|
|
||||||
},
|
},
|
||||||
|
|
||||||
shader: `
|
shader: `
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export const pylonField3D = {
|
|||||||
texture: 0.4,
|
texture: 0.4,
|
||||||
|
|
||||||
params: {
|
params: {
|
||||||
columns: { type: 'float', range: [3, 16], default: 8, uniform: 'u_columns', bias: 'density', slowAxis: true },
|
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' },
|
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' },
|
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' },
|
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' },
|
pulse: { type: 'float', range: [0, 1.2], default: 0.5, uniform: 'u_pulse', bias: 'energy' },
|
||||||
@@ -34,7 +34,6 @@ export const pylonField3D = {
|
|||||||
|
|
||||||
reactive: {
|
reactive: {
|
||||||
pulse: { feature: 'beat', amount: 0.4, response: 'smooth' },
|
pulse: { feature: 'beat', amount: 0.4, response: 'smooth' },
|
||||||
rows: { feature: 'bandLow', amount: 0.25 },
|
|
||||||
},
|
},
|
||||||
|
|
||||||
build({ scene, seed, params, actorSpec, THREE }) {
|
build({ scene, seed, params, actorSpec, THREE }) {
|
||||||
@@ -84,7 +83,6 @@ export const pylonField3D = {
|
|||||||
const t = timeline.time;
|
const t = timeline.time;
|
||||||
const beat = features.beat || 0;
|
const beat = features.beat || 0;
|
||||||
const beatPhase = features.beatPhase || 0;
|
const beatPhase = features.beatPhase || 0;
|
||||||
const bandLow = features.bandLow || 0;
|
|
||||||
|
|
||||||
const identity = personality ? personality.identity : null;
|
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]];
|
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));
|
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 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 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);
|
const height = Math.max(0.2, params.height);
|
||||||
|
|||||||
@@ -727,18 +727,20 @@ export const chamber = {
|
|||||||
const idx = g.userData.index || 0;
|
const idx = g.userData.index || 0;
|
||||||
const isActor = g.userData.kind === 'actor';
|
const isActor = g.userData.kind === 'actor';
|
||||||
// Actors are the subject: always dressed. Junk thins out in
|
// Actors are the subject: always dressed. Junk thins out in
|
||||||
// sparse setups via popMul, as before.
|
// sparse setups via popMul — as a SMOOTH opacity fade, never a
|
||||||
if (isActor) {
|
// visible toggle: the interpolated venueState crosses thresholds
|
||||||
g.visible = opacity > 0.01;
|
// at arbitrary mid-section moments, and hard cuts there read as
|
||||||
} else {
|
// 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 popMul = vState ? vState.popMul : 1;
|
||||||
const showIndex = idx / Math.max(1, instance.popCount);
|
const showIndex = idx / Math.max(1, instance.popCount);
|
||||||
g.visible = opacity > 0.01 && (showIndex < (0.35 + popMul * 0.75));
|
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) => {
|
g.traverse((mesh) => {
|
||||||
if (mesh.isMesh) {
|
if (mesh.isMesh) {
|
||||||
mesh.material.opacity = opacity;
|
mesh.material.opacity = opacity * fade;
|
||||||
mesh.material.transparent = opacity < 0.999;
|
mesh.material.transparent = opacity * fade < 0.999;
|
||||||
// The room's population breathes with the story arc: dim in
|
// The room's population breathes with the story arc: dim in
|
||||||
// setup, bright at the climax. Pure f(venueState), no clock.
|
// setup, bright at the climax. Pure f(venueState), no clock.
|
||||||
// Figures breathe brighter — they must read against their
|
// Figures breathe brighter — they must read against their
|
||||||
|
|||||||
@@ -764,7 +764,7 @@ export const ride = {
|
|||||||
// whole town; light/fog still arc within each.
|
// whole town; light/fog still arc within each.
|
||||||
if (instance.roadside) {
|
if (instance.roadside) {
|
||||||
const n = instance.roadside.children.length;
|
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));
|
const showUpTo = dense ? n : Math.max(3, Math.floor(n * 0.45));
|
||||||
// A billboard shot with a hidden billboard is an empty road: the
|
// A billboard shot with a hidden billboard is an empty road: the
|
||||||
// board's own slot is always on while it is the subject.
|
// board's own slot is always on while it is the subject.
|
||||||
|
|||||||
@@ -301,20 +301,27 @@ export const stage = {
|
|||||||
for (const pylon of instance.pylonContainer.children) {
|
for (const pylon of instance.pylonContainer.children) {
|
||||||
const crown = pylon.userData.crown;
|
const crown = pylon.userData.crown;
|
||||||
const rung = pylon.userData.rung;
|
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) {
|
if (crown) {
|
||||||
const yaw = t*0.35 + crown.userData.yawSeed + shapeTilt*0.2;
|
const yaw = t*0.35 + crown.userData.yawSeed + shapeTilt*0.2;
|
||||||
crown.rotation.order='YXZ';
|
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);
|
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) {
|
if (rung) {
|
||||||
rung.rotation.order='YXZ';
|
rung.rotation.order='YXZ';
|
||||||
rung.rotation.set(0, t*0.22 + rung.userData.yawSeed, 0);
|
rung.rotation.set(0, t*0.22 + rung.userData.yawSeed, 0);
|
||||||
rung.material.opacity=opacity*0.95;
|
rung.material.opacity=opacity*0.95*fade;
|
||||||
rung.visible=opacity>0.01;
|
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
|
// Field drift
|
||||||
instance.pylonContainer.position.x = Math.sin(t*camSwayRate)*camSway*0.5;
|
instance.pylonContainer.position.x = Math.sin(t*camSwayRate)*camSway*0.5;
|
||||||
|
|||||||
Reference in New Issue
Block a user