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:
Dejvino
2026-09-20 16:42:44 +02:00
parent 6e70f95d4d
commit a99a7cbe49
15 changed files with 308 additions and 31 deletions
+18 -1
View File
@@ -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];
+4 -1
View File
@@ -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;
+27 -3
View File
@@ -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);
+30
View File
@@ -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;
@@ -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' },
+1 -1
View File
@@ -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' },
@@ -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 },
@@ -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 },
@@ -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: `
@@ -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);
+12 -10
View File
@@ -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
+1 -1
View File
@@ -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.
+12 -5
View File
@@ -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;