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:
@@ -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,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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user