Venues: persistent room, motivated camera, ride; drop Pylon Grid
- Venue as one continuous place: persistent per-song layer, interpolated venueState, story jumps (one re-hang max + recap) with camera cuts - Room camera shot plan (venueShots.js): per-section size/target, monotonic look sweep, traverse glide body, room-side alternation - New Ride venue: generated vehicle + actor passengers, radio RTT screen, chase/stationary modes, ride archetype across generator/camera/staging - Flora cost trim (5x(12+6+6) -> 4x(8+4+4) SDF evals, 204ms -> 140ms worst) - Delete Pylon Grid shader (2.5s/frame SwiftShader); drop its metadata row - tools/venue-check.js headless gate harness + venue-gallery explorer
This commit is contained in:
@@ -78,6 +78,13 @@
|
||||
<a href="/filmstrip.html?song=centre,ember">?song=</a> just these ·
|
||||
<code>EPIC-4.md</code> for the arc it is checking</div>
|
||||
</div>
|
||||
<div class="card fast">
|
||||
<h3><a href="/venue-gallery.html">venue gallery</a></h3>
|
||||
<p>Venue × viz explorer — venue (higher-level than viz, picked first via census; hybrid
|
||||
projection per archetype) against viz behind it. Try a sad-slow chamber of memories vs. a
|
||||
pumping stage field from different angles and with different viz.</p>
|
||||
<div class="meta">rows = songs · try <code>?venue=1</code> in gallery/filmstrip/app</div>
|
||||
</div>
|
||||
<div class="card fast">
|
||||
<h3><a href="/">the app</a></h3>
|
||||
<p>Drop a track and watch it. <code>test/songs/*.wav</code> holds the
|
||||
|
||||
@@ -114,6 +114,7 @@ const params = new URLSearchParams(location.search);
|
||||
const every = Number(params.get('every')) || DEFAULT_EVERY;
|
||||
const duration = Number(params.get('duration')) || DEFAULT_DURATION;
|
||||
const only = params.get('song') ? params.get('song').split(',') : null;
|
||||
const venueEnabled = params.get('venue') === '1';
|
||||
|
||||
document.getElementById('everyLabel').textContent = every;
|
||||
|
||||
@@ -231,7 +232,7 @@ function summarise(built) {
|
||||
|
||||
// The cache key carries the sampling parameters as well as the source, so
|
||||
// changing `?every=` does not silently show you the previous spacing's pixels.
|
||||
const cacheKey = () => `filmstrip:${every}:${duration}:${only ? only.join(',') : 'all'}:${sourceFingerprint()}`;
|
||||
const cacheKey = () => `filmstrip:${every}:${duration}:${only ? only.join(',') : 'all'}:v${venueEnabled ? 1 : 0}:${sourceFingerprint()}`;
|
||||
|
||||
async function build() {
|
||||
bar.classList.add('building');
|
||||
@@ -241,7 +242,7 @@ async function build() {
|
||||
|
||||
const total = (only || songNames()).length;
|
||||
const built = await buildFilmstrip({
|
||||
duration, every, names: only,
|
||||
duration, every, names: only, venueEnabled,
|
||||
// Painted as they land, in bank order, rather than after the whole run.
|
||||
// A full build is a couple of minutes of GPU work and the interesting
|
||||
// failure — a strip whose frames could be shuffled — is visible in the
|
||||
|
||||
+51
-4
@@ -69,9 +69,10 @@ export class Show {
|
||||
|
||||
report('look', 0.97);
|
||||
const samples = monoSamples(audioBuffer);
|
||||
const wantsVenue = this._wantsVenue();
|
||||
this.setLook(seed === null
|
||||
? generateLook(this.track, { samples })
|
||||
: generateLook(this.track, { seed }));
|
||||
? generateLook(this.track, { samples, venueEnabled: wantsVenue })
|
||||
: generateLook(this.track, { seed, venueEnabled: wantsVenue }));
|
||||
|
||||
this.engine.timeline.setDuration(this.track.duration);
|
||||
this.engine.setFeatureProvider(featureProviderFor(this.track));
|
||||
@@ -97,7 +98,7 @@ export class Show {
|
||||
this.track = track;
|
||||
this.engine.timeline.setDuration(track.duration);
|
||||
this.engine.setFeatureProvider(featureProviderFor(track));
|
||||
this.setLook(look || generateLook(track, { seed: 1 }));
|
||||
this.setLook(look || generateLook(track, { seed: 1, venueEnabled: this._wantsVenue() }));
|
||||
this.prewarm();
|
||||
return this;
|
||||
}
|
||||
@@ -144,6 +145,37 @@ export class Show {
|
||||
this.engine.setSize(width, height);
|
||||
}
|
||||
|
||||
_wantsVenue() {
|
||||
try {
|
||||
const qp = typeof location !== 'undefined' ? new URLSearchParams(location.search) : null;
|
||||
if (qp) {
|
||||
const v = qp.get('venue');
|
||||
if (v === '1' || v === 'true' || v === 'on') return true;
|
||||
if (v && v !== '0' && v !== 'false' && v !== 'off') return true;
|
||||
}
|
||||
} catch { /* non-browser */ }
|
||||
if (this._venueOverride !== undefined) return !!this._venueOverride;
|
||||
return false;
|
||||
}
|
||||
|
||||
setVenueEnabled(enabled) {
|
||||
enabled = !!enabled;
|
||||
this._venueOverride = enabled;
|
||||
if (!this.track || !this.look) return;
|
||||
const fromSamples = this.audioBuffer ? monoSamples(this.audioBuffer) : null;
|
||||
const seed = this.look ? this.look.seed : 1;
|
||||
try {
|
||||
const next = fromSamples
|
||||
? generateLook(this.track, { samples: fromSamples, venueEnabled: enabled, seed: null })
|
||||
: generateLook(this.track, { seed, venueEnabled: enabled });
|
||||
const locked = new Map();
|
||||
for (const sec of this.look.sections) if (sec.locked) locked.set(sec.index, sec);
|
||||
for (let i = 0; i < next.sections.length; i++) if (locked.has(i)) next.sections[i] = locked.get(i);
|
||||
this.setLook(next);
|
||||
this.prewarm();
|
||||
} catch { /* leave look as-is on failure */ }
|
||||
}
|
||||
|
||||
/** Fade in at the head and out at the tail; nothing starts or ends abruptly. */
|
||||
_fadeAt(frame) {
|
||||
const fadeFrames = FADE_SECONDS * this.fps;
|
||||
@@ -208,11 +240,26 @@ export class Show {
|
||||
// nothing reads it. Pure f(frame,look) so seek === playback still holds.
|
||||
if (sceneLayers.some((l) => l && l.module && l.module.kind === 'model')) {
|
||||
const pers = this.look.personality;
|
||||
const framing = this.arc.framingForFrame(timeline.frame);
|
||||
const hasVenue = sceneLayers.some((l) => l && l.module && l.module.venue);
|
||||
// The room gets its own framing, not the viz's: the venue camera
|
||||
// relocates only on story jumps and otherwise holds, while the viz
|
||||
// framing cuts at every shot. Feeding the viz framing to the room
|
||||
// is what threw it around at every section. See _venueFramingAt.
|
||||
const framing = hasVenue && this.look.venue
|
||||
? this.arc.venueFramingForFrame(timeline.frame)
|
||||
: this.arc.framingForFrame(timeline.frame);
|
||||
this.engine.compositor.updateSharedCamera({
|
||||
framing,
|
||||
personality: pers,
|
||||
time: timeline.time,
|
||||
venue: hasVenue && this.look.venue ? this.look.venue : null,
|
||||
journey: this.arc.state ? this.arc.state.journey : 0,
|
||||
// Continuous 0→1 across the video. The story journey travels in
|
||||
// held stages (right for narrative, wrong for a camera dolly —
|
||||
// staged values jump at section starts); progress is monotonic
|
||||
// in the frame, so a traverse driven by it cannot jump.
|
||||
progress: timeline.frameCount > 1
|
||||
? timeline.frame / (timeline.frameCount - 1) : 0,
|
||||
});
|
||||
// Inject the shared camera into each active model layer so they render
|
||||
// through one perspective and one depth buffer.
|
||||
|
||||
@@ -69,11 +69,12 @@ function blockMean(byBlock) {
|
||||
*
|
||||
* @returns {{name, frames: Array, drift: number, direction: number, look}}
|
||||
*/
|
||||
export function renderSong(show, name, { duration, every }) {
|
||||
export function renderSong(show, name, { duration, every, venueEnabled = null }) {
|
||||
const { track } = song(name, { duration });
|
||||
// Seeded off the song's name, so a strip is stable between rebuilds and two
|
||||
// songs are not accidentally handed the same look.
|
||||
const look = generateLook(track, { seed: hashString(name) });
|
||||
const venueFlag = venueEnabled !== null ? !!venueEnabled : (typeof location !== 'undefined' && new URLSearchParams(location.search).get('venue') === '1');
|
||||
const look = generateLook(track, { seed: hashString(name), venueEnabled: venueFlag });
|
||||
show.useTrack(track, look);
|
||||
|
||||
const frames = [];
|
||||
@@ -162,6 +163,7 @@ export function renderSong(show, name, { duration, every }) {
|
||||
return {
|
||||
name,
|
||||
frames,
|
||||
look,
|
||||
drift: mean(dists),
|
||||
direction: directionOf(gaps, dists),
|
||||
// Agitation over the strip: worst 1 s floating window, max(flash/3,
|
||||
@@ -205,7 +207,7 @@ function momentAt(story, index) {
|
||||
* times for nothing.
|
||||
*/
|
||||
export async function buildFilmstrip({
|
||||
duration = DEFAULT_DURATION, every = DEFAULT_EVERY, names = null,
|
||||
duration = DEFAULT_DURATION, every = DEFAULT_EVERY, names = null, venueEnabled = null,
|
||||
onSong = null, onSongStart = null,
|
||||
} = {}) {
|
||||
const list = names && names.length ? names : songNames();
|
||||
@@ -223,9 +225,9 @@ export async function buildFilmstrip({
|
||||
}
|
||||
let row;
|
||||
try {
|
||||
row = renderSong(show, list[i], { duration, every });
|
||||
row = renderSong(show, list[i], { duration, every, venueEnabled });
|
||||
} catch (err) {
|
||||
row = { name: list[i], frames: [], drift: 0, direction: 0, error: err.message };
|
||||
row = { name: list[i], frames: [], shots: [], drift: 0, direction: 0, error: err.message };
|
||||
}
|
||||
rows.push(row);
|
||||
if (onSong) onSong(i + 1, list.length, row);
|
||||
|
||||
@@ -64,6 +64,12 @@ export class Compositor {
|
||||
const bh = Math.max(1, Math.floor(h / 2));
|
||||
|
||||
this.layerTarget = r.createTarget(w, h, { depth: true });
|
||||
// Viz render target for the venue RTT path: non-venue layers composite
|
||||
// here first, then the texture is bound onto the venue's screen and the
|
||||
// venue itself renders into layerTarget. Same size as the layer target
|
||||
// (aspect-preserving screens sample it; oversized is harmless, undersized
|
||||
// would smear text). Cleared with the rest in reset().
|
||||
this.vizTarget = r.createTarget(w, h, { depth: true });
|
||||
this.accumA = r.createTarget(w, h);
|
||||
this.accumB = r.createTarget(w, h);
|
||||
this.historyA = r.createTarget(w, h, { float: true });
|
||||
@@ -89,10 +95,25 @@ export class Compositor {
|
||||
this.sharedDepthTarget = this.renderer.createDepthTarget(this.width, this.height);
|
||||
}
|
||||
|
||||
/** Drive the shared perspective rig from the current framing + gaze + personality. */
|
||||
updateSharedCamera({ framing, personality, time }) {
|
||||
/**
|
||||
* Drive the shared perspective rig from the current framing + gaze + personality.
|
||||
*
|
||||
* `venue` selects the grammar. Without one the camera is the old subject
|
||||
* rig: near-still at dolly ~4, drift measured in hundredths, because that
|
||||
* camera films an object. A venue is a room the camera is INSIDE, and the
|
||||
* same hundredths read as a locked tripod in the middle of it — which is
|
||||
* why every venue render so far has been one static wide. The room grammar
|
||||
* walks: a slow lateral travel (±1.5) with a counter-pan so the screen stays
|
||||
* framed, a per-section push-in from the story journey, and a handheld
|
||||
* layer from churn. All analytic f(time, seed) — seek === playback.
|
||||
*/
|
||||
updateSharedCamera({ framing, personality, time, venue = null, journey = 0 }) {
|
||||
const cam = this.sharedCamera;
|
||||
const frame = framing || { scale: 1, shift: [0, 0] };
|
||||
if (venue) {
|
||||
this._updateVenueCamera(cam, { framing: frame, personality, time, venue, journey });
|
||||
return;
|
||||
}
|
||||
const dolly = 4 / Math.max(frame.scale, 0.05);
|
||||
const pCam = personality ? personality.camera : null;
|
||||
if (pCam) {
|
||||
@@ -114,6 +135,154 @@ export class Compositor {
|
||||
cam.updateMatrixWorld();
|
||||
}
|
||||
|
||||
_updateVenueCamera(cam, { framing, personality, time, venue, journey, progress = null }) {
|
||||
const pCam = personality ? personality.camera : null;
|
||||
const motion = personality ? personality.motion : null;
|
||||
const seed = (venue.seed >>> 0) || 1;
|
||||
const s1 = Math.sin(seed * 0.00013), s2 = Math.cos(seed * 0.00029);
|
||||
// Smaller rooms get a smaller walk: chamber is 22 wide, stage 44.
|
||||
const room = venue.archetype === 'chamber' ? 0.62 : venue.archetype === 'ride' ? 0.85 : 1.0;
|
||||
const still = motion ? motion.stillness : 0.5;
|
||||
const churn = motion ? motion.churn : 0.25;
|
||||
// Planned room shots (see look/venueShots.js): absolute per-cue marks
|
||||
// with a motive — screen, furniture, or the whole room. When the arc
|
||||
// serves one, it replaces the traverse/push/gaze stack below outright;
|
||||
// only the handheld texture survives. No plan (hand-built looks, old
|
||||
// tests driving this method directly) → the legacy traverse path.
|
||||
const planned = framing && framing.venueShot ? framing.venueShot : null;
|
||||
// The walk: a TRAVERSE, not a sway. Two oscillators were tried here and
|
||||
// both failed the same way — a 75 s period put consecutive 30 s probes
|
||||
// half a cycle apart, and even a 300 s period swings back within a long
|
||||
// video, so near-in-time frames differed more than far-apart ones and
|
||||
// filmstrip direction went negative. The staged story journey was tried
|
||||
// next and jumped at section starts for the same reason. The camera
|
||||
// instead crosses the room once, driven by continuous video progress
|
||||
// (monotonic in the frame — it cannot jump), while the push-in below
|
||||
// stays journey-staged so arrivals still land per section. Direction
|
||||
// from the seed so two tracks walk different lines; counter-pan below
|
||||
// keeps the screen framed. Stillness LENGTHENS the crossing: a pumping
|
||||
// track changes its viz constantly, a still one does not, so the camera
|
||||
// does the travelling the music doesn't — otherwise slow songs go
|
||||
// static in both layers at once (two flat filmstrips on the slowest
|
||||
// bank songs). A small two-sine handheld keeps it alive in a hold.
|
||||
const walkDir = s1 >= 0 ? 1 : -1;
|
||||
const walkSpan = (1.1 + still * 0.9) * walkDir;
|
||||
const trav = progress !== null && progress !== undefined ? progress : journey;
|
||||
// Glide, not drift: the lateral crossing is only one axis of travel.
|
||||
// A second, slower cosine on a different phase swings the camera across
|
||||
// the room's middle over ~2 minutes, so the path arcs instead of
|
||||
// drawing a straight line — straight lines read as static at 30 s
|
||||
// probe spacing once the endpoints are familiar.
|
||||
const walk = (trav - 0.5) * 2 * walkSpan
|
||||
+ Math.cos(time * 0.043 + s2 * 4.2) * walkSpan * 0.55
|
||||
+ Math.sin(time * 0.23 + s1 * 3.1) * 0.08
|
||||
+ Math.sin(time * 0.11 + s2 * 5.0) * 0.06;
|
||||
// The push: story journey 0→1 walks the camera closer over the video,
|
||||
// staged through per-section steps by the journey curve itself. Sized
|
||||
// so consecutive 30 s probes see the room at measurably different
|
||||
// scales — a static camera in a static room is churn, not an arc.
|
||||
// The dolly ALSO marches on continuous progress underneath the staged
|
||||
// push, so even a journey that holds for a minute keeps gliding closer.
|
||||
const push = (journey - 0.5) * 2.2 * room
|
||||
+ (trav - 0.5) * 1.6 * room;
|
||||
// Handheld: churn-scaled, two incommensurate sines so it never loops.
|
||||
const hx = Math.sin(time * 0.9 + s1 * 7.0) * 0.035 * (0.4 + churn)
|
||||
+ Math.sin(time * 0.23 + s2 * 5.0) * 0.05 * (0.4 + churn);
|
||||
const hy = Math.cos(time * 0.77 + s2 * 9.0) * 0.03 * (0.4 + churn)
|
||||
+ Math.sin(time * 0.31 + s1 * 4.0) * 0.04 * (0.4 + churn);
|
||||
const sway = pCam ? pCam.sway : 0.01;
|
||||
const swayRate = pCam ? pCam.swayRate : 0.09;
|
||||
if (planned) {
|
||||
// A planned mark: the BODY walks the traverse (continuous,
|
||||
// monotonic — the thing that arcs), while the HEAD takes the
|
||||
// plan's look target (screen / room). Position marks were tried and
|
||||
// reverted: discrete positions made held compositions that all
|
||||
// looked alike, and blending positions with the walk broke the
|
||||
// walk's time correlation. The look is where the motive lives — a
|
||||
// pan cut over a gliding body reads as following the action. Jump
|
||||
// sections cut the body to the arrangement side (the furniture
|
||||
// swapped; gliding through the old layout is the glitch).
|
||||
const lim = venue.archetype === 'chamber'
|
||||
? { x: 2.4, yLo: -0.2, yHi: 1.4, zLo: 0.8, zHi: 7.0 }
|
||||
: venue.archetype === 'ride'
|
||||
? { x: 4.0, yLo: -0.4, yHi: 2.2, zLo: -6.0, zHi: 6.0 }
|
||||
: { x: 4.0, yLo: -0.2, yHi: 2.0, zLo: 1.0, zHi: 9.0 };
|
||||
const isJump = framing.venueJump !== undefined && framing.venueJump !== null;
|
||||
if (isJump) {
|
||||
cam.position.set(
|
||||
Math.max(-lim.x, Math.min(lim.x, planned.pos[0] + hx)),
|
||||
Math.max(lim.yLo, Math.min(lim.yHi, planned.pos[1] + hy)),
|
||||
Math.max(lim.zLo, Math.min(lim.zHi, planned.pos[2])),
|
||||
);
|
||||
} else {
|
||||
// Same traverse body as the legacy path below (walk + rise +
|
||||
// sway + gaze share): one body, two heads. The plan's per-shot
|
||||
// size scales the dolly (a close stands nearer).
|
||||
const baseZ = venue.archetype === 'chamber' ? 4.0 : venue.archetype === 'ride' ? 3.4 : 4.6;
|
||||
const sizeDolly = (planned.dolly && planned.dolly > 0) ? planned.dolly : 1.0;
|
||||
const dolly = (baseZ / Math.max(framing.scale, 0.05) - Math.max(0, push)) * sizeDolly;
|
||||
const rise = (trav - 0.5) * 0.9 * room;
|
||||
const shiftX = framing.shift[0] * 0.6;
|
||||
const shiftY = framing.shift[1] * 0.35;
|
||||
cam.position.set(
|
||||
walk * 1.5 * room + Math.sin(time * swayRate) * sway * 4 + shiftX + hx,
|
||||
Math.max(lim.yLo, Math.min(lim.yHi,
|
||||
0.35 + rise + Math.cos(time * swayRate * 0.83) * sway * 3 + shiftY + hy)),
|
||||
Math.max(lim.zLo, Math.min(lim.zHi, dolly)),
|
||||
);
|
||||
}
|
||||
cam.rotation.z = pCam ? pCam.spin * time * 0.5 : 0;
|
||||
cam.lookAt(planned.look[0], planned.look[1], planned.look[2]);
|
||||
cam.updateMatrixWorld();
|
||||
return;
|
||||
}
|
||||
// Close enough that the screen fills the frame: the chamber window is
|
||||
// 12 wide at z=-10.9, so z≈4 makes it ~40% of a 60° frame.
|
||||
const baseZ = venue.archetype === 'chamber' ? 4.0 : venue.archetype === 'ride' ? 3.4 : 4.6;
|
||||
const dolly = baseZ / Math.max(framing.scale, 0.05) - Math.max(0, push);
|
||||
// A story jump relocates the room camera for the section: the furniture
|
||||
// swapped at its start (see ArcDriver.venueJumpAt), so the camera
|
||||
// MUST be somewhere new too — a new room filmed from the old mark
|
||||
// reads as the glitch, while a new room from a new angle reads as a
|
||||
// cut. The framing carries the jump side; the traverse continues
|
||||
// underneath, so the walk resumes from the new mark without snapping
|
||||
// back. Pure f(framing, time) — the jump id is in the framing.
|
||||
const jumpShift = (framing && framing.venueJump !== undefined && framing.venueJump !== null)
|
||||
? framing.shift : null;
|
||||
// Gaze shifts relocate at section starts; the venue camera takes a
|
||||
// third of them — the room should sit stiller than the viz, and the
|
||||
// counter-pan already keeps the screen framed through the traverse.
|
||||
// (Kept small deliberately: full gaze jumps threw the whole room
|
||||
// around at every section, ~4x the no-venue section distance.)
|
||||
// On a jump frame the jump shift replaces the gaze shift outright.
|
||||
// The mix is continuous in time EXCEPT the jump cut: gaze moves ease
|
||||
// with the section (they are per-cue plans evaluated live), so the
|
||||
// camera reframes at every section boundary instead of teleporting.
|
||||
const shiftX = jumpShift ? jumpShift[0] : framing.shift[0] * 0.6;
|
||||
const shiftY = jumpShift ? jumpShift[1] : framing.shift[1] * 0.35;
|
||||
// Vertical rise: the camera climbs as it pushes in — low wide early,
|
||||
// higher and closer late — so the video gains a second travel axis the
|
||||
// walk cannot provide. Clamped to the room box: the test asserts
|
||||
// |x| < 2.6 and z in 0.4..8, and the bigger swing must not break it.
|
||||
const rise = (trav - 0.5) * 0.9 * room;
|
||||
const limX = venue.archetype === 'chamber' ? 2.4 : venue.archetype === 'ride' ? 4.5 : 4.0;
|
||||
cam.position.set(
|
||||
Math.max(-limX, Math.min(limX,
|
||||
walk * 1.5 * room + Math.sin(time * swayRate) * sway * 4 + shiftX + hx)),
|
||||
0.35 + rise + Math.cos(time * swayRate * 0.83) * sway * 3 + shiftY + hy,
|
||||
Math.max(0.6, Math.min(7.5, dolly)),
|
||||
);
|
||||
cam.rotation.z = pCam ? pCam.spin * time * 0.5 : 0;
|
||||
// Counter-pan: look back toward the screen by a fraction of the walk, so
|
||||
// the camera travels without losing the room's subject. Was 0.35 — but
|
||||
// that cancelled the traverse visually (the camera walked while its
|
||||
// view stayed pinned, so layout never travelled and direction stayed
|
||||
// flat). 0.15 lets the walk reframe while keeping the screen in view.
|
||||
const lookX = cam.position.x * 0.15;
|
||||
cam.lookAt(lookX, cam.position.y * 0.3 - 0.25, cam.position.z - 5);
|
||||
cam.updateMatrixWorld();
|
||||
}
|
||||
|
||||
_buildMaterials() {
|
||||
this.blendMaterial = makePassMaterial(BLEND_FRAG, {
|
||||
u_base: { value: null },
|
||||
@@ -271,7 +440,7 @@ export class Compositor {
|
||||
reset() {
|
||||
const r = this.renderer;
|
||||
[this.accumA, this.accumB, this.historyA, this.historyB,
|
||||
this.bloomA, this.bloomB, this.layerTarget, this.outputTarget]
|
||||
this.bloomA, this.bloomB, this.layerTarget, this.vizTarget, this.outputTarget]
|
||||
.forEach((t) => r.clear(t));
|
||||
}
|
||||
|
||||
@@ -291,7 +460,90 @@ export class Compositor {
|
||||
? this.layers.slice(this.soloIndex, this.soloIndex + 1)
|
||||
: this.layers;
|
||||
|
||||
for (const layer of active) {
|
||||
// --- venue RTT path -------------------------------------------------
|
||||
// When a venue layer is present, the viz must appear ON its screen, not
|
||||
// composited over it. So: non-venue layers composite into vizTarget,
|
||||
// the viz texture is bound onto each venue screen, then only venues
|
||||
// (+ OSD) render fullscreen into layerTarget. OSD rides the fullscreen
|
||||
// list so it is never baked into the viz texture. Without a venue this
|
||||
// reduces to the old loop (everything renders via layerTarget).
|
||||
// OSD detection must not depend on minification: constructor.name does
|
||||
// not survive a production build. The OSD module is the one layer with
|
||||
// no family/kind/venue — identify it structurally instead.
|
||||
const isOSD = (l) => !l.module || (!l.module.kind && !l.module.family && !l.module.venue);
|
||||
const venueActive = active.filter((l) => l.module && l.module.venue && l.opacity > 0.001);
|
||||
const hasVenue = venueActive.length > 0;
|
||||
// The plate belongs on the main screen only: keep it out of the viz
|
||||
// texture (it would render miniature on every venue screen) and let it
|
||||
// ride the fullscreen list instead.
|
||||
const vizLayers = hasVenue ? active.filter((l) => !(l.module && l.module.venue) && !isOSD(l)) : active;
|
||||
const fsLayers = hasVenue
|
||||
? active.filter((l) => (l.module && l.module.venue) || isOSD(l))
|
||||
: active;
|
||||
|
||||
if (hasVenue) {
|
||||
// Composite the viz stack into vizTarget, alternating with spare
|
||||
// (accumB — still untouched at this point; the fullscreen chain
|
||||
// below clears accumA first and fully overwrites spare on its first
|
||||
// blend, so borrowing it here is safe). layerTarget stays the
|
||||
// per-layer render scratch. A fullscreen blit overwrites its whole
|
||||
// target, so no clear is needed before each blend.
|
||||
// Clear to the venue's wash, not black: the shot keys over the
|
||||
// room's own colour, so the screen shows shot-over-room rather
|
||||
// than the forbidden shot-floating-on-black. The wash arcs with
|
||||
// venueState (it is read off live room materials), so it carries
|
||||
// slow travel while calming section jumps. See Layer.washColor.
|
||||
{
|
||||
const wash = venueActive.length ? venueActive[0].washColor() : [0, 0, 0];
|
||||
r.clear(this.vizTarget, wash[0], wash[1], wash[2], 1);
|
||||
}
|
||||
let accumV = this.vizTarget;
|
||||
let spareV = spare;
|
||||
for (const layer of vizLayers) {
|
||||
if (layer.opacity <= 0.001) continue;
|
||||
|
||||
r.clear(this.layerTarget);
|
||||
// prevTexture feeds prev()-style feedback sampling. The viz
|
||||
// composite is its own chain, so hand it the viz accumulator —
|
||||
// never the fullscreen history, which the venue screens below
|
||||
// would otherwise smear back into themselves.
|
||||
layer.render(r, this.layerTarget, {
|
||||
timeline,
|
||||
features,
|
||||
prevTexture: accumV.texture,
|
||||
});
|
||||
|
||||
const bu = this.blendMaterial.uniforms;
|
||||
bu.u_base.value = accumV.texture;
|
||||
bu.u_src.value = this.layerTarget.texture;
|
||||
bu.u_amount.value = 1.0; // layer opacity already applied in-shader
|
||||
bu.u_mode.value = BLEND_MODE_IDS[layer.blend] ?? 0;
|
||||
r.blit(this.blendMaterial, spareV);
|
||||
|
||||
const t = accumV; accumV = spareV; spareV = t;
|
||||
}
|
||||
// The finished viz must live in vizTarget for the bind below.
|
||||
if (accumV !== this.vizTarget) {
|
||||
this.copyMaterial.uniforms.u_tex.value = accumV.texture;
|
||||
r.blit(this.copyMaterial, this.vizTarget);
|
||||
}
|
||||
// Bind AFTER the viz composite is complete: every venue screen
|
||||
// samples the finished viz, and binding is per-layer (each venue
|
||||
// owns its materials, never shared).
|
||||
for (const venue of venueActive) {
|
||||
try {
|
||||
if (typeof venue.bindVizTexture === 'function') {
|
||||
venue.bindVizTexture(this.vizTarget.texture);
|
||||
} else if (venue.instance && typeof venue.module.bindVizTexture === 'function') {
|
||||
venue.module.bindVizTexture(venue.instance, this.vizTarget.texture);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[compositor] viz bind failed for', venue.module && venue.module.name, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const layer of (hasVenue ? fsLayers : active)) {
|
||||
if (layer.opacity <= 0.001) continue;
|
||||
|
||||
r.clear(this.layerTarget);
|
||||
@@ -391,7 +643,7 @@ export class Compositor {
|
||||
}
|
||||
|
||||
disposeTargets() {
|
||||
[this.layerTarget, this.accumA, this.accumB, this.historyA, this.historyB,
|
||||
[this.layerTarget, this.vizTarget, this.accumA, this.accumB, this.historyA, this.historyB,
|
||||
this.bloomA, this.bloomB, this.outputTarget,
|
||||
this.sharedDepthTarget].forEach((t) => t && t.dispose());
|
||||
this.sharedDepthTarget = null;
|
||||
|
||||
@@ -145,6 +145,7 @@ export class Layer {
|
||||
this.blend = blend;
|
||||
this.palette = [];
|
||||
this.personality = null;
|
||||
this.venueState = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -374,6 +375,7 @@ export class ModelLayer extends Layer {
|
||||
this.camera = new THREE.PerspectiveCamera(60, 16 / 9, 0.1, 200);
|
||||
this.camera.position.set(0, 0, 5);
|
||||
this.actorSpec = options.actorSpec || null;
|
||||
this.venueSpec = options.venueSpec || null;
|
||||
// Shared rig injected by Compositor at render time when available.
|
||||
this.sharedCamera = null;
|
||||
this.instance = this.module.build({
|
||||
@@ -382,10 +384,48 @@ export class ModelLayer extends Layer {
|
||||
seed: this.seed,
|
||||
params: this.baseParams,
|
||||
actorSpec: this.actorSpec,
|
||||
venueSpec: this.venueSpec,
|
||||
THREE,
|
||||
});
|
||||
}
|
||||
|
||||
/** Allow the look to swap the venue without rebuilding the layer. */
|
||||
setVenue(venueSpec) {
|
||||
this.venueSpec = venueSpec || null;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind the composited viz texture onto this layer's screen (venue RTT).
|
||||
* No-op unless the module implements bindVizTexture(instance, texture).
|
||||
* Delegating keeps venue materials owned by the venue module — the
|
||||
* compositor never touches venue meshes directly.
|
||||
*/
|
||||
bindVizTexture(texture) {
|
||||
if (this.module && typeof this.module.bindVizTexture === 'function' && this.instance) {
|
||||
this.module.bindVizTexture(this.instance, texture);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base wash for the viz composite. The shot is keyed over this colour
|
||||
* instead of over black: a shot floating on black is the composition
|
||||
* failure the phase 12 rules forbid, miniaturised onto the venue screen —
|
||||
* and it measured ~5x the no-venue section distance (sparse bright things
|
||||
* on nothing maximise layout/region jumps). Defaults to a dark neutral;
|
||||
* venue modules override via washColor(instance) to match their room.
|
||||
*/
|
||||
washColor() {
|
||||
try {
|
||||
if (this.module && typeof this.module.washColor === 'function' && this.instance) {
|
||||
const c = this.module.washColor(this.instance);
|
||||
if (Array.isArray(c) && c.length >= 3) return [c[0], c[1], c[2]];
|
||||
}
|
||||
} catch { /* fall through to default */ }
|
||||
return [0.03, 0.03, 0.04];
|
||||
}
|
||||
|
||||
/** Allow the look to swap the actor without rebuilding the layer. */
|
||||
setActor(actorSpec) {
|
||||
this.actorSpec = actorSpec || null;
|
||||
@@ -418,6 +458,13 @@ export class ModelLayer extends Layer {
|
||||
framing: this.framing,
|
||||
opacity: this.opacity,
|
||||
actorSpec: this.actorSpec,
|
||||
venueSpec: this.venueSpec,
|
||||
venueState: this.venueState,
|
||||
// Song-level arrangement salt (see ArcDriver.venueJumpAt): which
|
||||
// dressing of the room is live. Null most of the time; the module
|
||||
// re-hangs only when it changes. Plain field, not on Layer base —
|
||||
// only venue modules read it.
|
||||
venueArrangement: this.venueArrangement ?? null,
|
||||
THREE,
|
||||
});
|
||||
renderer.renderScene(this.scene, cam, target, true);
|
||||
|
||||
@@ -9,12 +9,37 @@ import { VERTEX_SHADER } from './shader-contract.js';
|
||||
*/
|
||||
export class Renderer {
|
||||
constructor({ width = 1280, height = 720, canvas = null } = {}) {
|
||||
this.gl = new THREE.WebGLRenderer({
|
||||
canvas: canvas || undefined,
|
||||
antialias: false, // we render through targets; MSAA here buys nothing
|
||||
preserveDrawingBuffer: true, // required to read pixels back for hashing/export
|
||||
powerPreference: 'high-performance',
|
||||
});
|
||||
// three@0.181 requires WebGL2. When the browser blocks it (disabled
|
||||
// pref/policy, headless shell without SwiftShader), the constructor
|
||||
// throws — historically a blank page with only a console error, since
|
||||
// Show is built at module scope in main.js. Catch it here and rethrow
|
||||
// a message that names the cause and the fix, so every caller (app,
|
||||
// gallery, filmstrip, venue-gallery) can surface it instead of dying
|
||||
// silent. this.gl stays null so dispose()/getters degrade safely.
|
||||
let gl = null;
|
||||
try {
|
||||
gl = new THREE.WebGLRenderer({
|
||||
canvas: canvas || undefined,
|
||||
antialias: false, // we render through targets; MSAA here buys nothing
|
||||
preserveDrawingBuffer: true, // required to read pixels back for hashing/export
|
||||
powerPreference: 'high-performance',
|
||||
});
|
||||
} catch (err) {
|
||||
const reason = err && err.message ? String(err.message).slice(0, 300) : String(err);
|
||||
throw new Error(
|
||||
`WebGL2 unavailable — the renderer could not create a WebGL context (${reason}). ` +
|
||||
`Enable WebGL2 in this browser (Firefox: webgl.allow-webgl2) or use Chromium/Chrome ` +
|
||||
`with hardware GL or SwiftShader (--enable-unsafe-swiftshader, without --disable-gpu).`,
|
||||
);
|
||||
}
|
||||
if (!gl || !gl.getContext()) {
|
||||
throw new Error(
|
||||
'WebGL2 unavailable — the renderer got no GL context back. ' +
|
||||
'Enable WebGL2 in this browser (Firefox: webgl.allow-webgl2) or use Chromium/Chrome ' +
|
||||
'with hardware GL or SwiftShader (--enable-unsafe-swiftshader, without --disable-gpu).',
|
||||
);
|
||||
}
|
||||
this.gl = gl;
|
||||
this.gl.autoClear = false;
|
||||
this.gl.setPixelRatio(1); // never device-dependent: output size is explicit
|
||||
this.gl.setSize(width, height, false);
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { subjectIndexOf, isGround } from './stack.js';
|
||||
import { groundPersonalityFrom } from '../scenes/surface.js';
|
||||
@@ -49,6 +50,7 @@ export class ArcDriver {
|
||||
? look.framing : null;
|
||||
this._planFraming();
|
||||
this._planGaze();
|
||||
this._planVenueShots();
|
||||
this._planPalette();
|
||||
this.state = { sectionIndex: 0, shotIndex: 0, crossfade: 0, incoming: null };
|
||||
}
|
||||
@@ -95,7 +97,48 @@ export class ArcDriver {
|
||||
_planGaze() {
|
||||
if (!this.look.camera) return;
|
||||
const rng = new Rng((this.look.seed ^ 0x2f9c1d4b) >>> 0);
|
||||
this.gaze = planGaze(this.cues, this.look.sections, this.look.camera, rng);
|
||||
// Venue story jumps force a gaze relocate on the same cue, so the viz
|
||||
// cut covers the room re-hang. Computed from the same venueJumpAt the
|
||||
// renderer reads — one decision, two expressions.
|
||||
let force = null;
|
||||
if (this.look && this.look.venue && this.look.venue.rearrangeAt) {
|
||||
force = new Set();
|
||||
for (const cue of this.cues) {
|
||||
if (this.venueJumpAt(cue)) force.add(cue.index);
|
||||
}
|
||||
if (!force.size) force = null;
|
||||
}
|
||||
this.gaze = planGaze(this.cues, this.look.sections, this.look.camera, rng, force);
|
||||
}
|
||||
|
||||
/**
|
||||
* Plan the ROOM camera's shots across the whole video.
|
||||
*
|
||||
* After gaze, for the same reason: the room's shot sizes lean on the same
|
||||
* framing style the viz uses, and a seek must find the same mark playback
|
||||
* does. No venue (or a hand-built look) → no plan; the room camera falls
|
||||
* back to its traverse path (see Compositor._updateVenueCamera).
|
||||
*/
|
||||
_planVenueShots() {
|
||||
const venue = this.look && this.look.venue;
|
||||
if (!venue || !venue.module) { this.venueShots = null; return; }
|
||||
// The room plans with an unlocked style even on a locked track: locked
|
||||
// is the viz's decision to never change size, and a locked-off screen
|
||||
// inside a room that still pushes wide→close is a legitimate combination
|
||||
// (the screen holds still; the house moves). Pinning the room too froze
|
||||
// every dolly on locked tracks.
|
||||
const style = this.look.framing && this.look.framing.mode === 'locked'
|
||||
? { ...this.look.framing, mode: 'gentle', range: 0.45, changeChance: 0.35 }
|
||||
: this.look.framing;
|
||||
const rng = new Rng((this.look.seed ^ 0x6d1f4a77) >>> 0);
|
||||
// The viz gaze move per cue, so the room shot inherits a fraction of
|
||||
// the viz camera's relocation: the two cameras cut in the same
|
||||
// direction, and the room keeps the per-cue variation the traverse era
|
||||
// got from the gaze coupling (dropping it flattened everything).
|
||||
const gazeFrom = (this.gaze || []).map((m) => (m && m.from) || [0, 0]);
|
||||
this.venueShots = planVenueShots(
|
||||
this.cues, this.look.sections, venue.archetype,
|
||||
style, (cue) => this.venueJumpAt(cue), rng, gazeFrom);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -221,37 +264,110 @@ export class ArcDriver {
|
||||
return stack[slot] || null;
|
||||
}
|
||||
|
||||
/** Resolve the VenueSpec for the venue ground. */
|
||||
_venueFor() {
|
||||
// Venue is the track's place — one per track. Return the Look's venue when requested.
|
||||
return (this.look && this.look.venue) ? this.look.venue : null;
|
||||
}
|
||||
|
||||
/** Resolve the ActorSpec for a layer, if the module requests one. */
|
||||
_actorFor(module) {
|
||||
const actors = this.look.actors;
|
||||
if (!actors || !module || !module.actor) return null;
|
||||
if (!actors || !module) return null;
|
||||
// Venues are furnished with the track's monolith body: the room's
|
||||
// memories / pylons are the same character as the viz hero, which is
|
||||
// what makes the venue read as this track's place rather than a set.
|
||||
if (module.venue) return actors.monolith || null;
|
||||
if (!module.actor) return null;
|
||||
return actors[module.actor] || null;
|
||||
}
|
||||
|
||||
/** One Layer per (section, variant, layer slot), built lazily and kept. */
|
||||
_layerFor(sectionIndex, variant, slot = 0) {
|
||||
const spec = this._specFor(sectionIndex, variant, slot);
|
||||
// The venue is the exception: one persistent layer for the whole song,
|
||||
// not one per section. A per-section venue rebuilds the room at every
|
||||
// boundary and dissolves old-room into new-room — two offset copies of
|
||||
// the same furniture ghosting through each other, which reads as a
|
||||
// glitch rather than a cut. See _venueLayer. (Hand-built looks with a
|
||||
// venue module in a stack but no look.venue fall through to the normal
|
||||
// path — persistence needs the track-level spec.)
|
||||
if (spec.module && spec.module.venue && this.look && this.look.venue && this.look.venue.module) {
|
||||
return this._venueLayer();
|
||||
}
|
||||
const key = `${sectionIndex}:${variant}:${slot}`;
|
||||
let layer = this.layerCache.get(key);
|
||||
if (!layer) {
|
||||
const spec = this._specFor(sectionIndex, variant, slot);
|
||||
const actorSpec = this._actorFor(spec.module);
|
||||
const venueSpec = spec.module && spec.module.venue ? this._venueFor() : null;
|
||||
layer = createLayer(spec.module, {
|
||||
params: spec.params,
|
||||
seed: spec.seed,
|
||||
opacity: spec.opacity,
|
||||
blend: spec.blend,
|
||||
actorSpec,
|
||||
venueSpec,
|
||||
});
|
||||
// For venue grounds, also bind venueSpec as actorSpec-like for geometry
|
||||
layer.setPalette(this.look.palette);
|
||||
layer.setPersonality(this.look.personality);
|
||||
// ModelLayers can have their actor swapped without being rebuilt — the
|
||||
// mesh is imposter-free so the geometry can be re-bound live.
|
||||
if (actorSpec && layer.setActor) layer.setActor(actorSpec);
|
||||
if (venueSpec && layer.setVenue) layer.setVenue(venueSpec);
|
||||
this.layerCache.set(key, layer);
|
||||
}
|
||||
return layer;
|
||||
}
|
||||
|
||||
/**
|
||||
* The venue layer: exactly one per ArcDriver, living for the whole song.
|
||||
*
|
||||
* Its params and seed come from the track-level venue.params sampled in
|
||||
* generateLook — never re-sampled per section — so the furniture layout is
|
||||
* the layout the song opened with. Per-section evolution arrives only as
|
||||
* venueState (interpolated rest values, see _venueStateAt) plus the
|
||||
* arrangement salt (see venueJumpAt): same objects, new dressing, and only
|
||||
* ever with a camera cut to cover the swap.
|
||||
*
|
||||
* Keyed 'venue' in the layer cache, outside the section:v:slot namespace,
|
||||
* so invalidateSection can never dispose it out from under a crossfade.
|
||||
*/
|
||||
_venueLayer() {
|
||||
let layer = this.layerCache.get('venue');
|
||||
if (!layer) {
|
||||
const venue = this._venueFor();
|
||||
const spec = this._venueSpec();
|
||||
const actorSpec = this._actorFor(venue.module);
|
||||
layer = createLayer(venue.module, {
|
||||
params: (venue && venue.params) || (spec && spec.params) || {},
|
||||
seed: (venue && Number.isInteger(venue.seed) ? venue.seed : null)
|
||||
?? (spec && spec.seed) ?? 1,
|
||||
opacity: 1,
|
||||
blend: 'normal',
|
||||
actorSpec,
|
||||
venueSpec: venue,
|
||||
});
|
||||
layer.setPalette(this.look.palette);
|
||||
layer.setPersonality(this.look.personality);
|
||||
if (actorSpec && layer.setActor) layer.setActor(actorSpec);
|
||||
if (venue && layer.setVenue) layer.setVenue(venue);
|
||||
this.layerCache.set('venue', layer);
|
||||
}
|
||||
return layer;
|
||||
}
|
||||
|
||||
/** The ground-slot spec of the first section that stands on the venue. */
|
||||
_venueSpec() {
|
||||
for (const section of this.look.sections || []) {
|
||||
for (const stack of section.variants || [section.layers]) {
|
||||
const at = (stack || []).findIndex((l) => l && l.module && l.module.venue);
|
||||
if (at >= 0) return stack[at];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-param LFO plan for a section: amplitude, period and phase, all seeded.
|
||||
* Slow enough to read as evolution rather than wobble — 20 to 70 seconds.
|
||||
@@ -447,6 +563,92 @@ export class ArcDriver {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Base params for the persistent venue layer at a given time.
|
||||
*
|
||||
* The base is the track-level venue.params — one draw for the whole song —
|
||||
* plus the same slow axis and drift every other layer gets. Deliberately
|
||||
* NOT the per-section ground spec: re-sampling the room per section is the
|
||||
* furniture swap this change kills. Drift and slow axis use a fixed
|
||||
* section/variant/slot address (0:0:0) so the plan is stable no matter
|
||||
* which cue is on screen; the values are continuous in time, so nothing
|
||||
* jumps at a boundary. Lookahead is skipped — the room anticipates nothing,
|
||||
* it arrives (see _venueStateAt).
|
||||
*/
|
||||
_venueParamsAt(cue, time, features, story = null) {
|
||||
const venue = this.look && this.look.venue;
|
||||
const base = (venue && venue.params) || {};
|
||||
const out = { ...base };
|
||||
const module = venue ? venue.module : null;
|
||||
if (!module) return out;
|
||||
|
||||
let journey;
|
||||
if (story) journey = story.journey;
|
||||
else {
|
||||
const duration = Math.max(1e-6, this.track.duration);
|
||||
const p = Math.max(0, Math.min(1, time / duration));
|
||||
journey = p * p * (3 - 2 * p);
|
||||
}
|
||||
for (const item of this._slowAxisFor(module)) {
|
||||
const b = out[item.name];
|
||||
if (typeof b !== 'number') continue;
|
||||
out[item.name] = clampValue(item.def, b + item.travel * (journey - 0.5));
|
||||
}
|
||||
for (const item of this._driftPlan(0, 0, 0)) {
|
||||
const b = out[item.name];
|
||||
if (typeof b !== 'number') continue;
|
||||
const wave = Math.sin(2 * Math.PI * (time / item.period + item.phase));
|
||||
out[item.name] = clampValue(item.def, b + wave * item.amplitude);
|
||||
}
|
||||
void cue; void features;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Framing for the persistent venue layer: the room's own shot, not the
|
||||
* viz's. Served from the per-cue venue shot plan (see _planVenueShots):
|
||||
* each cue eases to its own mark — size steps and look targets with a
|
||||
* motive — instead of holding one framing for the whole song. A story jump
|
||||
* still cuts instantly to the new arrangement's side (the furniture
|
||||
* swapped; an eased move would glide through the old layout). Pure f(cue,
|
||||
* frame) like _framingAt. No plan (no venue, hand-built look) → neutral.
|
||||
*/
|
||||
_venueFramingAt(cueIndex, frame) {
|
||||
const cue = this.cues[cueIndex];
|
||||
const jump = this.venueJumpAt(cue);
|
||||
// Marks are per-cue for a dense look path, but size/target hold across
|
||||
// each section's phrases and step at story beats. The dolly eases over
|
||||
// the cue so size cuts read AS cuts; the look travels by progress.
|
||||
if (this.venueShots && this.venueShots.length && cue) {
|
||||
const frames = this.track.frameCount || 1;
|
||||
const progress = frames > 1 ? frame / (frames - 1) : 0;
|
||||
const shot = venueShotAt(this.venueShots, cueIndex, frame,
|
||||
cue.startFrame, cue.endFrame, progress);
|
||||
if (shot) {
|
||||
return {
|
||||
size: `venue-${shot.size}`,
|
||||
scale: 1,
|
||||
shift: [0, 0],
|
||||
venueShot: shot,
|
||||
venueJump: jump ? jump.arrangement : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
// Fallback: the old hold/jump grammar (also what covers hand-built
|
||||
// looks that never got a plan).
|
||||
if (jump) {
|
||||
const h = ((this.look.seed || 1) ^ Math.imul(jump.arrangement + 1, 0x9e3779b9)) >>> 0;
|
||||
const side = (h % 2 === 0) ? 1 : -1;
|
||||
return {
|
||||
size: 'venue-jump',
|
||||
scale: 1,
|
||||
shift: [side * 0.55, ((h >> 8) % 100) / 100 * 0.2 - 0.1],
|
||||
venueJump: jump.arrangement,
|
||||
};
|
||||
}
|
||||
return { size: 'venue-hold', scale: 1, shift: [0, 0] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The buildSlope value on the frame before a boundary. Read from the table
|
||||
* rather than remembered, so a seek and playback agree.
|
||||
@@ -650,6 +852,28 @@ export class ArcDriver {
|
||||
|
||||
const layers = [];
|
||||
|
||||
// The venue is one continuous place, not a per-section image: the
|
||||
// persistent venue layer carries over across every boundary, and is
|
||||
// NEVER part of the outgoing/incoming dissolve. Dissolving old-room
|
||||
// into new-room is the glitch this change kills — two offset copies of
|
||||
// the same furniture ghosting through each other. The room drifts via
|
||||
// venueState; it only ever jumps with a camera cut (see venueJumpAt).
|
||||
const venueJump = this.venueJumpAt(cue);
|
||||
const venueState = (this.look && this.look.venue) ? this._venueStateAt(frame) : null;
|
||||
const pushVenue = (spec, layer, params, venueFraming, opacity) => {
|
||||
layer.setParams(params);
|
||||
layer.opacity = opacity;
|
||||
layer.blend = 'normal';
|
||||
layer.setPalette(palette);
|
||||
layer.setPersonality(groundPersonalityFrom(personality));
|
||||
layer.setFraming(venueFraming);
|
||||
layer.venueState = venueState;
|
||||
// Arrangement salt: same objects, new dressing — applied on the
|
||||
// jump frame itself, under the camera cut. Null most of the time.
|
||||
layer.venueArrangement = venueJump ? venueJump.arrangement : null;
|
||||
layers.push(layer);
|
||||
};
|
||||
|
||||
if (fading) {
|
||||
// buildSlope is discontinuous at a section boundary by construction:
|
||||
// it ramps to ~1 through the bars before the change and is 0
|
||||
@@ -665,6 +889,10 @@ export class ArcDriver {
|
||||
|
||||
for (let slot = 0; slot < this._stackSize(previous); slot++) {
|
||||
const spec = this._specFor(previous.sectionIndex, previous.variant, slot);
|
||||
// Venue slot of the outgoing stack: skip it here, the persistent
|
||||
// layer is pushed once below. (Its pre-jump dressing, if any, is
|
||||
// already behind us — the jump lands on the cue frame.)
|
||||
if (spec && spec.module && spec.module.venue) continue;
|
||||
const layer = this._layerFor(previous.sectionIndex, previous.variant, slot);
|
||||
layer.setParams(this._paramsAt(previous, slot, time, outgoingFeatures, story));
|
||||
layer.opacity = slot === 0 ? 1 : spec.opacity;
|
||||
@@ -676,9 +904,17 @@ export class ArcDriver {
|
||||
}
|
||||
}
|
||||
|
||||
// Incoming stack. Slot 0 is the venue ground when the venue is on: the
|
||||
// persistent layer takes its place at full opacity, undissolved. Every
|
||||
// other slot dissolves in over it exactly as before.
|
||||
const venueFraming = this._venueFramingAt(cueIndex, frame);
|
||||
for (let slot = 0; slot < this._stackSize(cue); slot++) {
|
||||
const spec = this._specFor(cue.sectionIndex, cue.variant, slot);
|
||||
const layer = this._layerFor(cue.sectionIndex, cue.variant, slot);
|
||||
if (spec && spec.module && spec.module.venue) {
|
||||
pushVenue(spec, layer, this._venueParamsAt(cue, time, features, story), venueFraming, 1);
|
||||
continue;
|
||||
}
|
||||
layer.setParams(this._paramsAt(cue, slot, time, features, story));
|
||||
layer.opacity = (slot === 0 ? 1 : spec.opacity) * (fading ? eased : 1);
|
||||
layer.blend = slot === 0 ? 'normal' : spec.blend;
|
||||
@@ -707,12 +943,95 @@ export class ArcDriver {
|
||||
tension: story.tension,
|
||||
reveal: story.reveal,
|
||||
journey: story.journey,
|
||||
venueName: this.look && this.look.venue ? this.look.venue.name : null,
|
||||
venueArchetype: this.look && this.look.venue ? this.look.venue.archetype : null,
|
||||
venueState,
|
||||
venueShot: venueFraming && venueFraming.venueShot ? {
|
||||
size: venueFraming.venueShot.size,
|
||||
target: venueFraming.venueShot.target,
|
||||
} : null,
|
||||
};
|
||||
|
||||
this.activeLayers = layers;
|
||||
return layers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-venue story state at frame — INTERPOLATED between section rests.
|
||||
*
|
||||
* The old version stepped discretely at each boundary, so the room's light,
|
||||
* fog and population all jumped on the same frame the viz cut. The room
|
||||
* must drift while the viz cuts: rest values come from
|
||||
* VenueSpec.perSection, and the first half of each section eases from the
|
||||
* previous section's rest to this one's. Pure f(frame) — a seek lands on
|
||||
* the same blend as playback, and the midpoint of a long section is fully
|
||||
* arrived (no endless slide).
|
||||
*/
|
||||
_venueStateAt(frame) {
|
||||
const venue = this.look && this.look.venue;
|
||||
if (!venue || !venue.perSection || !venue.perSection.length) return null;
|
||||
const idx = this._venueSectionIndexAt(frame);
|
||||
const cur = venue.perSection[idx] || venue.perSection[0];
|
||||
if (idx <= 0) return { ...cur };
|
||||
const prev = venue.perSection[idx - 1] || cur;
|
||||
const sec = (this.look.sections || [])[idx];
|
||||
let t = 1;
|
||||
if (sec && sec.endFrame > sec.startFrame) {
|
||||
// Arrive over the first half of the section, hold the rest.
|
||||
t = Math.max(0, Math.min(1, (frame - sec.startFrame) / ((sec.endFrame - sec.startFrame) * 0.5)));
|
||||
t = t * t * (3 - 2 * t);
|
||||
}
|
||||
const out = {};
|
||||
for (const k of Object.keys(cur)) {
|
||||
const a = prev[k], b = cur[k];
|
||||
out[k] = (typeof a === 'number' && typeof b === 'number') ? a + (b - a) * t : b;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Section index owning a frame, for venue state and jumps. */
|
||||
_venueSectionIndexAt(frame) {
|
||||
const venue = this.look && this.look.venue;
|
||||
const n = (venue && venue.perSection && venue.perSection.length)
|
||||
|| (this.look.sections || []).length;
|
||||
const story = this.look.story;
|
||||
if (story && story.frames && story.frames.length) {
|
||||
for (let i = Math.min(story.frames.length, n) - 1; i >= 0; i--) {
|
||||
if (frame >= story.frames[i].startFrame) return Math.min(i, n - 1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
const secs = this.look.sections || [];
|
||||
for (let i = Math.min(secs.length, n) - 1; i >= 0; i--) {
|
||||
const sec = secs[i];
|
||||
if (frame >= sec.startFrame && frame < sec.endFrame) return Math.min(i, n - 1);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the venue rearranges on this cue, and which arrangement is live.
|
||||
*
|
||||
* Returns null when the room only drifts (the common case): furniture
|
||||
* carries over untouched. Returns {arrangement} at a section-start cue
|
||||
* whose rearrangeAt value differs from the previous section's — the room
|
||||
* re-hangs itself, and the caller MUST pair it with a camera cut
|
||||
* (see _venueFramingAt / Camera.relocatesAt), because an uncovered
|
||||
* furniture swap is the glitch this whole change exists to kill.
|
||||
*
|
||||
* Pure f(cue): arrangement ids come from VenueSpec.rearrangeAt, decided at
|
||||
* generation time. The outro returns to arrangement 0 (the intro's room).
|
||||
*/
|
||||
venueJumpAt(cue) {
|
||||
const venue = this.look && this.look.venue;
|
||||
if (!venue || !venue.rearrangeAt || !venue.rearrangeAt.length) return null;
|
||||
if (!cue || !cue.atSectionStart || cue.sectionIndex <= 0) return null;
|
||||
const before = venue.rearrangeAt[cue.sectionIndex - 1] ?? 0;
|
||||
const at = venue.rearrangeAt[cue.sectionIndex] ?? before;
|
||||
if (at === before) return null;
|
||||
return { arrangement: at, from: before };
|
||||
}
|
||||
|
||||
/** Which slot of a cue's stack is the shot. See look/stack.js. */
|
||||
_subjectSlot(cue) {
|
||||
return subjectIndexOf(this._stackFor(cue));
|
||||
@@ -736,6 +1055,9 @@ export class ArcDriver {
|
||||
/** Invalidate caches for one section after an edit or reroll. */
|
||||
invalidateSection(sectionIndex) {
|
||||
for (const key of [...this.layerCache.keys()]) {
|
||||
// The venue layer ('venue') outlives every section: a reroll
|
||||
// re-casts the section's viz, never the room it stands in.
|
||||
if (key === 'venue') continue;
|
||||
if (key.startsWith(`${sectionIndex}:`)) {
|
||||
this.layerCache.get(key).dispose();
|
||||
this.layerCache.delete(key);
|
||||
@@ -746,6 +1068,7 @@ export class ArcDriver {
|
||||
this.cues = this._buildCues();
|
||||
this._planFraming();
|
||||
this._planGaze();
|
||||
this._planVenueShots();
|
||||
this._planPalette();
|
||||
this._paletteKey = null;
|
||||
this._slopeCache = null;
|
||||
@@ -757,6 +1080,7 @@ export class ArcDriver {
|
||||
this.cues = this._buildCues();
|
||||
this._planFraming();
|
||||
this._planGaze();
|
||||
this._planVenueShots();
|
||||
this._planPalette();
|
||||
this._paletteKey = null;
|
||||
this._slopeCache = null;
|
||||
@@ -777,6 +1101,12 @@ export class ArcDriver {
|
||||
if (onLayer) onLayer(layer);
|
||||
}
|
||||
}
|
||||
// The persistent venue layer is built once here so the first frame
|
||||
// never pays for its geometry.
|
||||
if (this.look && this.look.venue && this.look.venue.module) {
|
||||
const layer = this._venueLayer();
|
||||
if (onLayer) onLayer(layer);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -821,6 +1151,11 @@ export class ArcDriver {
|
||||
return this._framingAt(this._cueIndexAt(frame), frame);
|
||||
}
|
||||
|
||||
/** The room's own framing for a frame — holds across cuts, jumps on story jumps. */
|
||||
venueFramingForFrame(frame) {
|
||||
return this._venueFramingAt(this._cueIndexAt(frame), frame);
|
||||
}
|
||||
|
||||
personalityForFrame(frame, story) {
|
||||
const s = story ?? (this.look.story ? storyStateAt(this.look.story, frame) : null);
|
||||
return this._personalityAt(s);
|
||||
|
||||
@@ -303,9 +303,13 @@ function relocatesAt(cue, story, rng) {
|
||||
* @param {Array} sections look.sections, for bias and story state
|
||||
* @param {object} camera from deriveCamera
|
||||
* @param {Rng} rng
|
||||
* @param {Set} [forceRelocateAt] cue indexes that MUST relocate the gaze —
|
||||
* venue story jumps (see ArcDriver.venueJumpAt). The room re-hangs on those
|
||||
* frames, so the viz camera cuts with it: two cuts landing together read as
|
||||
* one edit, while a room swap under a walking camera reads as a glitch.
|
||||
* @returns {Array} one move per cue, index-aligned
|
||||
*/
|
||||
export function planGaze(cues, sections, camera, rng) {
|
||||
export function planGaze(cues, sections, camera, rng, forceRelocateAt = null) {
|
||||
const moves = [];
|
||||
let at = [0, 0];
|
||||
let previousDir = null;
|
||||
@@ -329,7 +333,8 @@ export function planGaze(cues, sections, camera, rng) {
|
||||
// snap this causes lands exactly on a cut, which is where the eye is
|
||||
// least able to see it.
|
||||
let from = clampToReach(at, reach);
|
||||
if (moves.length && relocatesAt(cue, story, rng)) {
|
||||
if (moves.length && (relocatesAt(cue, story, rng)
|
||||
|| (forceRelocateAt && forceRelocateAt.has(cue.index)))) {
|
||||
const hop = jumpFor(story, !!cue.hardCut, rng) * reach;
|
||||
const jumped = targetFor(from, hop, camera, story, rng, previousDir);
|
||||
from = clampToReach(jumped.point, reach);
|
||||
|
||||
@@ -31,6 +31,7 @@ import { deriveFramingStyle, describeFraming } from './framing.js';
|
||||
import { deriveCamera, describeCamera } from './Camera.js';
|
||||
import { deriveStory, storyForSection, NEUTRAL_STATE } from './Story.js';
|
||||
import { generateActorSet, describeActorSet } from '../actors/ActorGenerator.js';
|
||||
import { generateVenue, describeVenue } from '../venues/VenueGenerator.js';
|
||||
|
||||
// Which families suit which section kind now comes from the track's DIRECTOR
|
||||
// (look/directors.js) rather than from a constant here. The coupling it
|
||||
@@ -248,6 +249,7 @@ function castingPool(rng, signature, size = POOL_SIZE) {
|
||||
return picked;
|
||||
}
|
||||
|
||||
let _activeVenueFamily = null; // set per-track for venue-family lean
|
||||
function candidatesForKind(kind, used, signature = [], director, pool = null) {
|
||||
const families = director.families[kind] || Object.keys(FAMILIES);
|
||||
const allowed = pool ? new Set(pool.map((m) => m.name)) : null;
|
||||
@@ -272,7 +274,8 @@ function candidatesForKind(kind, used, signature = [], director, pool = null) {
|
||||
// Personality.signatureWeight for why it had to stop being one.
|
||||
weight: weight * signatureWeight(scene, signature)
|
||||
* (used.has(scene.name) ? 0.15 : 1)
|
||||
* agitationWeight(scene, comfortCeiling),
|
||||
* agitationWeight(scene, comfortCeiling)
|
||||
* (_activeVenueFamily && scene.family === _activeVenueFamily ? 0.55 : 1),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -618,9 +621,11 @@ function contrastWeight(candidate, stack) {
|
||||
function buildStack(module, overlayRoster, bias, rng, temperament, story = null,
|
||||
{ ground = null, director = null, kind = null } = {}) {
|
||||
const sectionKind = kind;
|
||||
// A shot that fills the frame by itself is its own ground; anything else
|
||||
// gets one under it.
|
||||
const standsAlone = canGround(module) || !ground;
|
||||
// Venue is always the ground — even a canvas shot stands on its venue.
|
||||
// (canGround(venue) is false by design, so the old rule would think a
|
||||
// canvas viz needs no ground and drop the venue.)
|
||||
const isVenueGround = !!(ground && ground.venue);
|
||||
const standsAlone = !isVenueGround && (canGround(module) || !ground);
|
||||
|
||||
// --- the blaze ------------------------------------------------------
|
||||
// Whether THIS section is one the director lets bloom out: the shot added
|
||||
@@ -633,9 +638,13 @@ function buildStack(module, overlayRoster, bias, rng, temperament, story = null,
|
||||
// the director's appetite, times a loud section, times a late point in the
|
||||
// story. Quiet kinds never blaze; a breakdown that goes white is not a
|
||||
// decision, it is a bug with a rationale.
|
||||
// A venue rates its own reach: the chamber's dim plaster earns the blaze
|
||||
// often, the stage's 14-unit emissive wall rarely. Defaults to 1 (the old
|
||||
// behaviour) for scene grounds, which carry no rating.
|
||||
const groundReach = isVenueGround && ground.reach !== undefined ? ground.reach : 1;
|
||||
const blaze = !standsAlone
|
||||
&& !QUIET_KINDS.includes(sectionKind)
|
||||
&& rng.bool(blazeOf(director) * clamp01(bias.energy * 1.2)
|
||||
&& rng.bool(blazeOf(director) * groundReach * clamp01(bias.energy * 1.2)
|
||||
* (story ? 0.4 + story.tension * 0.9 : 0.7));
|
||||
|
||||
// The shot is sampled first and from the caller's rng, so a stack draws the
|
||||
@@ -820,6 +829,7 @@ function buildStack(module, overlayRoster, bias, rng, temperament, story = null,
|
||||
export function generateLook(track, {
|
||||
seed = null, samples = null, overrides = null,
|
||||
pool: poolOverride = null, poolSize = POOL_SIZE,
|
||||
venueEnabled = false,
|
||||
} = {}) {
|
||||
const resolvedSeed = seed !== null
|
||||
? seed >>> 0
|
||||
@@ -858,8 +868,38 @@ export function generateLook(track, {
|
||||
// section, and whether the outro answers the intro. See look/Story.js.
|
||||
const story = deriveStory(track, summary, rng.fork('story'));
|
||||
|
||||
// Venue — the place the track lives. Higher-level than viz, picked first.
|
||||
// Behind flag in Phase 0 so existing videos/Gates stay green.
|
||||
//
|
||||
// The venue's render params are sampled ONCE per track, not per section:
|
||||
// the room is one continuous place, so its density/light/etc. must be one
|
||||
// continuous draw. Per-section evolution arrives separately as venueState
|
||||
// (interpolated rest values, see ArcDriver._venueStateAt), never as a
|
||||
// re-sample — re-sampling per section is what used to swap the furniture
|
||||
// out from under the camera at every boundary.
|
||||
let venue = null;
|
||||
if (venueEnabled) {
|
||||
try {
|
||||
venue = generateVenue({
|
||||
summary, rng: rng.fork('venue'),
|
||||
personality, director, story,
|
||||
sections: track.sections,
|
||||
});
|
||||
if (venue && venue.module) {
|
||||
const vRng = rng.fork(`venue:params:${venue.name}`);
|
||||
const vBias = { ...biasFor(track.sections[0], summary, personality.motion, storyForSection(story, 0)), energy: 0.55, density: 0.55 };
|
||||
venue.params = sampleValues(
|
||||
venue.module, vRng, groundBiasFrom(vBias),
|
||||
groundTemperamentFrom(personality.temperament));
|
||||
venue.seed = vRng.int(0, 0x7fffffff);
|
||||
}
|
||||
} catch (e) { venue = null; }
|
||||
}
|
||||
|
||||
_activeVenueFamily = venue ? venue.module.family : null;
|
||||
const rosterByKind = assignRostersByKind(
|
||||
track.sections, rng.fork('scenes'), personality.signature, director, pool);
|
||||
_activeVenueFamily = null;
|
||||
if (story.recap) applyRecap(rosterByKind);
|
||||
// What each kind's sections stand on. After the recap, so the outro is
|
||||
// grounded against the roster it actually ends up with.
|
||||
@@ -906,7 +946,14 @@ export function generateLook(track, {
|
||||
// so the last occurrence of a kind samples further out than the first.
|
||||
const temperament = temperamentFor(personality.temperament, state);
|
||||
|
||||
const ground = groundByKind.get(section.kind) || null;
|
||||
// When the venue is on it IS the ground for every section — one place,
|
||||
// one module. The ArcDriver renders it as a single persistent layer
|
||||
// (see _venueLayer), so the per-section ground spec below is only the
|
||||
// stack slot the venue occupies; its params/seed come from the
|
||||
// track-level venue.params sampled above, not from here.
|
||||
const ground = (venueEnabled && venue && venue.module)
|
||||
? venue.module
|
||||
: (groundByKind.get(section.kind) || null);
|
||||
|
||||
const variants = roster.map((module, v) => buildStack(
|
||||
module, overlayRoster, bias,
|
||||
@@ -940,6 +987,8 @@ export function generateLook(track, {
|
||||
|
||||
const look = {
|
||||
seed: resolvedSeed,
|
||||
venue,
|
||||
venueEnabled,
|
||||
palette,
|
||||
// The full set the director cuts between — ArcDriver reads this per cue.
|
||||
palettes,
|
||||
@@ -1104,8 +1153,11 @@ export function rerollSection(look, track, sectionIndex, salt = 0) {
|
||||
const state = section.story || NEUTRAL_STATE;
|
||||
// The section is re-cast, so its ground is re-cast with it — a reroll that
|
||||
// kept the old bed under new shots would be answering half the question.
|
||||
const ground = castGround(
|
||||
section.kind, roster, rng.fork('ground'), signature, director, new Set());
|
||||
// When venue is enabled the venue IS the ground and must not be re-cast per section.
|
||||
const isVenue = !!(look.venueEnabled && look.venue && look.venue.module);
|
||||
const ground = isVenue
|
||||
? look.venue.module
|
||||
: castGround(section.kind, roster, rng.fork('ground'), signature, director, new Set());
|
||||
section.variants = roster.map((module, v) => buildStack(
|
||||
module, overlayRoster, section.bias, rng.fork(`variant:${v}`),
|
||||
temperamentFor(look.personality && look.personality.temperament, state), state,
|
||||
@@ -1123,7 +1175,7 @@ export function rerollLook(look, track, newSeed) {
|
||||
const locked = new Map();
|
||||
look.sections.forEach((s) => { if (s.locked) locked.set(s.index, s); });
|
||||
|
||||
const next = generateLook(track, { seed: newSeed >>> 0 });
|
||||
const next = generateLook(track, { seed: newSeed >>> 0, venueEnabled: !!look.venueEnabled });
|
||||
next.sections.forEach((s, i) => {
|
||||
if (locked.has(i)) next.sections[i] = locked.get(i);
|
||||
});
|
||||
@@ -1145,6 +1197,7 @@ function applyOverrides(look, overrides) {
|
||||
look.palette = look.palettes[0] || look.palette;
|
||||
}
|
||||
if (overrides.paletteSchemes) look.paletteSchemes = overrides.paletteSchemes;
|
||||
if (overrides.venue !== undefined) look.venue = overrides.venue;
|
||||
if (overrides.post) look.post = { ...look.post, ...overrides.post };
|
||||
if (overrides.feedback) look.feedback = { ...look.feedback, ...overrides.feedback };
|
||||
if (overrides.sections) {
|
||||
@@ -1167,10 +1220,11 @@ export function describeLook(look) {
|
||||
? ` · palettes:${look.palettePlan.progression}/${look.palettePlan.transition}`
|
||||
: '';
|
||||
const actorTag = look.actors ? ` · ${describeActorSet(look.actors)}` : '';
|
||||
const venueTag = look.venue ? ` · ${describeVenue(look.venue)}` : '';
|
||||
return `seed ${look.seed.toString(16)} · ${look.director} · ${palTag} · ` +
|
||||
`${describePersonality(look.personality)} · ${describeGrain(look.grain)} · ` +
|
||||
`${describePaletteArc(look.paletteArc)} · ${describeFraming(look.framing)} · ` +
|
||||
`${describeCamera(look.camera)}${planTag}${actorTag} · ` +
|
||||
`${describeCamera(look.camera)}${planTag}${actorTag}${venueTag} · ` +
|
||||
`${[...new Set(kinds)].join(', ')}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
// Where the ROOM camera is, and what it is looking at — per shot, planned once.
|
||||
//
|
||||
// The viz has framing (wide/normal/close) and gaze (a continuous path); the
|
||||
// room camera used to have one slow traverse and stare forward while the body
|
||||
// glided. So the room gets its own shot plan, with the same two devices as
|
||||
// the viz: SIZE steps at cuts (a close answered by a wide reads as two shots
|
||||
// of one place), and a look target with a MOTIVE — the screen when the viz is
|
||||
// the action, the whole room when the song breathes.
|
||||
//
|
||||
// The plan is data, evaluated per frame as a pure function like the gaze. The
|
||||
// BODY glides on the continuous traverse (the thing that arcs); the HEAD
|
||||
// follows a slow progress-driven path through the marks' anchors, leaning
|
||||
// past halfway at whatever the current section is about. A story jump cuts
|
||||
// instantly to the new arrangement's side instead of easing, because the
|
||||
// furniture swapped and an eased move would glide through the old layout.
|
||||
//
|
||||
// Bounds are per archetype and conservative: the camera must never leave the
|
||||
// room or enter the furniture. See test/venue.test.js for the box.
|
||||
|
||||
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
|
||||
/**
|
||||
* Room geometry per archetype: where the screen is, where the furniture
|
||||
* lives, and the box the camera may occupy. Positions are world units in the
|
||||
* venue module's scene (chamber room is 22 wide, stage field 44).
|
||||
*/
|
||||
export const VENUE_STAGING = {
|
||||
chamber: {
|
||||
screen: [0, 1.35, -10.9],
|
||||
fieldL: [-3.2, -0.4, -4.5],
|
||||
fieldR: [3.2, -0.4, -4.5],
|
||||
// Room anchor sits OFF the screen axis: a room visit must pan the
|
||||
// screen aside, or the composition never changes no matter how far the
|
||||
// body glides (measured: screen-centred room views 0.08 apart at 120 s
|
||||
// gaps). The side alternates per visit (see placeMark).
|
||||
roomMid: [0, 0.3, -5],
|
||||
roomSide: 2.8,
|
||||
box: { x: 2.4, yLo: -0.2, yHi: 1.4, zLo: 0.8, zHi: 7.0 },
|
||||
// Dolly per shot size: close sits among the furniture, wide sees the room.
|
||||
dolly: { wide: 6.2, normal: 4.0, close: 2.0 },
|
||||
},
|
||||
stage: {
|
||||
screen: [0, 2.2, -14.5],
|
||||
fieldL: [-4.5, 0.0, -6.0],
|
||||
fieldR: [4.5, 0.0, -6.0],
|
||||
roomMid: [0, 0.4, -7],
|
||||
roomSide: 4.2,
|
||||
box: { x: 4.0, yLo: -0.2, yHi: 2.0, zLo: 1.0, zHi: 9.0 },
|
||||
dolly: { wide: 8.2, normal: 4.6, close: 2.4 },
|
||||
},
|
||||
// The ride: the "screen" is the dash radio on its stalk at (0, 1.15,
|
||||
// 0.9), facing +z toward the default camera; the car sits at the origin
|
||||
// driving toward -z. The camera works the car like a chase unit — orbits,
|
||||
// push-ins on the radio, pull-backs for the road — inside a box that keeps
|
||||
// it out of the bodywork.
|
||||
ride: {
|
||||
screen: [0, 1.15, 0.9],
|
||||
fieldL: [-3.4, 0.2, -3.0],
|
||||
fieldR: [3.4, 0.2, -3.0],
|
||||
roomMid: [0, 0.3, -6],
|
||||
roomSide: 3.6,
|
||||
// Tighter than stage: the car is small, so the camera works closer
|
||||
// or the vehicle drowns in black road (measured 6% painted wide).
|
||||
box: { x: 4.0, yLo: -0.4, yHi: 2.2, zLo: -6.0, zHi: 6.0 },
|
||||
dolly: { wide: 5.5, normal: 3.4, close: 1.8 },
|
||||
},
|
||||
};
|
||||
|
||||
/** Shot sizes for the room — same three words as the viz, different numbers. */
|
||||
export const VENUE_SHOT_SIZES = ['wide', 'normal', 'close'];
|
||||
|
||||
/**
|
||||
* Plan one camera mark per cue — but size/target move at SECTION granularity.
|
||||
*
|
||||
* Two densities, one plan. The look path needs DENSE anchors (every cue): it
|
||||
* interpolates through them continuously, and five section anchors over three
|
||||
* minutes is a static stare. Size and target move per SECTION: the room holds
|
||||
* its shot across phrases (near probes share the framing) and steps at story
|
||||
* beats. Per-cue sizes made the room as twitchy as the viz — twenty marks a
|
||||
* song, every probe on a different scale, all pairs equally far (shuffle).
|
||||
* Intra-section movement is continuous travel; inter-section movement steps.
|
||||
* That split is the arc.
|
||||
*
|
||||
* @param {Array} cues ArcDriver cues (need index, sectionIndex, atSectionStart, fadeFrames)
|
||||
* @param {Array} sections look.sections (need story, bias)
|
||||
* @param {string} archetype 'chamber' | 'stage' | 'ride'
|
||||
* @param {object} style look.framing style ({mode, range, changeChance}); 'locked' parks the room
|
||||
* @param {Function} isJump (cue) => jump|null — arrangement jumps (see ArcDriver.venueJumpAt)
|
||||
* @param {Rng} rng
|
||||
* @param {Array} [gazeFrom] viz gaze `from` point per cue — the room takes a
|
||||
* fraction as lateral offset, so both cameras cut the same way
|
||||
* @returns {Array} one mark per cue ({size, target, pos, look, jump}); size and
|
||||
* target are constant within a section, pos/look vary per cue
|
||||
*/
|
||||
export function planVenueShots(cues, sections, archetype, style, isJump, rng, gazeFrom = null) {
|
||||
const staging = VENUE_STAGING[archetype] || VENUE_STAGING.chamber;
|
||||
// Locked pins SIZE (like the viz: a locked track never changes size) but
|
||||
// never attention — a video that never changes size can still be one whose
|
||||
// attention moves (see look/Camera.js). Parking the look target too is
|
||||
// what froze the room on locked tracks.
|
||||
const locked = !style || style.mode === 'locked';
|
||||
// The video globally pushes IN: lateral marches failed because mirrored
|
||||
// views of a symmetric room look alike (near probes differed by target,
|
||||
// far probes converged — negative direction). A dolly march is
|
||||
// irreversible — far-apart probes are different scales, guaranteed — while
|
||||
// per-cue sizes still step around the marching centre so cuts read.
|
||||
const n = Math.max(1, cues.length);
|
||||
const marks = [];
|
||||
let previous = null;
|
||||
// The room's off-axis home for this video, drawn once: room visits face
|
||||
// one wall all song (see placeMark). Drawn here — once, before the loop —
|
||||
// so rng consumo order is seek-stable.
|
||||
const roomSide = rng.bool(0.5) ? 1 : -1;
|
||||
|
||||
// Size/target are decided once per section and carried across its cues;
|
||||
// pos/look are drawn per cue so the look path stays dense. The streak
|
||||
// breaker reads sectionTargets (one entry per section): a cue-granularity
|
||||
// history would break every streak on its third cue, since all cues in a
|
||||
// section share the target.
|
||||
let sectionSize = null, sectionTarget = null, sectionIndex = -1;
|
||||
const sectionTargets = [];
|
||||
for (const cue of cues) {
|
||||
const section = sections[cue.sectionIndex] || {};
|
||||
const story = section.story || {};
|
||||
const energy = (section.bias && section.bias.energy) ?? 0.5;
|
||||
const jump = isJump ? isJump(cue) : null;
|
||||
|
||||
if (cue.sectionIndex !== sectionIndex) {
|
||||
sectionIndex = cue.sectionIndex;
|
||||
const prevMark = marks.length ? marks[marks.length - 1] : null;
|
||||
sectionSize = locked ? 'normal' : pickSize(prevMark, energy, story, style, rng);
|
||||
sectionTarget = pickTarget(prevMark, energy, story, rng,
|
||||
sectionTargets.map((t) => ({ target: t })));
|
||||
sectionTargets.push(sectionTarget);
|
||||
}
|
||||
const progress = n > 1 ? cue.index / (n - 1) : 0.5;
|
||||
const gaze = gazeFrom && gazeFrom[cue.index] ? gazeFrom[cue.index] : [0, 0];
|
||||
const mark = placeMark(staging, sectionSize, sectionTarget, jump, rng, progress, gaze, roomSide);
|
||||
|
||||
marks.push({
|
||||
index: cue.index,
|
||||
size: sectionSize, target: sectionTarget,
|
||||
pos: mark.pos,
|
||||
look: mark.look,
|
||||
jump: jump ? jump.arrangement : null,
|
||||
// The move eases over the dissolve, except on a jump cut.
|
||||
easeFrames: jump ? 0 : Math.max(1, cue.fadeFrames || 12),
|
||||
});
|
||||
}
|
||||
return marks;
|
||||
}
|
||||
|
||||
/** Shot size steps at cuts: never repeat, loud leans close, quiet leans wide. */
|
||||
function pickSize(previous, energy, story, style, rng) {
|
||||
const closeness = story.closeness ?? 0.5;
|
||||
// A locked-off track still gets its one size from the same draw.
|
||||
if (previous && style && !rng.bool(style.changeChance ?? 0.6)) return previous.size;
|
||||
const act = story.act || 'development';
|
||||
// Builds tighten toward the hit; the climax lands close; resolutions and
|
||||
// quiet opens go wide. Multipliers on the energy lean, not overrides — a
|
||||
// quiet climax can still play wide.
|
||||
let wideM = 1, closeM = 1;
|
||||
if (act === 'setup' || act === 'resolution') { wideM = 1.8; closeM = 0.5; }
|
||||
else if (act === 'climax') { closeM = 1.8; wideM = 0.5; }
|
||||
const weights = [
|
||||
(1 + (1 - energy) * 2.5) * (1.4 - closeness * 0.9) * wideM, // wide
|
||||
2, // normal
|
||||
(1 + energy * 2.5) * (0.6 + closeness * 0.9) * closeM, // close
|
||||
];
|
||||
let size = rng.pickWeighted(VENUE_SHOT_SIZES, weights);
|
||||
if (previous && size === previous.size) {
|
||||
size = rng.pick(VENUE_SHOT_SIZES.filter((s) => s !== size));
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
/**
|
||||
* WHAT the camera looks at — the motive.
|
||||
*
|
||||
* screen the viz is the action: face the screen.
|
||||
* room the song breathes: the whole place.
|
||||
*
|
||||
* Deliberately two targets, not three. Furniture visits (fieldL/fieldR) were
|
||||
* tried and measured as outliers — 0.2+ from every other probe, flattening or
|
||||
* inverting direction wherever they landed. The screen is the stage, the room
|
||||
* is the house; the camera does not cut to the audience mid-song. Furniture
|
||||
* still crosses the frame — the walk passes it — it is just never the mark.
|
||||
* Rotation between the two (never three in a row) is the coverage.
|
||||
*/
|
||||
function pickTarget(previous, energy, story, rng, history) {
|
||||
const last2 = (history || []).slice(-2).map((m) => m.target);
|
||||
const streak = last2.length === 2 && last2[0] === last2[1] ? last2[0] : null;
|
||||
const act = story.act || 'development';
|
||||
const tension = story.tension ?? 0.5;
|
||||
let pick;
|
||||
if (act === 'setup') pick = 'room';
|
||||
else if (act === 'resolution') pick = rng.bool(0.3) ? 'screen' : 'room';
|
||||
else if (act === 'climax') pick = 'screen';
|
||||
else if (act === 'turn') pick = rng.bool(0.75) ? 'screen' : 'room';
|
||||
else if (energy > 0.66 || tension > 0.7) pick = 'screen';
|
||||
else if (energy < 0.36) pick = 'room';
|
||||
else pick = rng.bool(0.5) ? 'screen' : 'room';
|
||||
if (streak && pick === streak) pick = streak === 'screen' ? 'room' : 'screen';
|
||||
void previous;
|
||||
return pick;
|
||||
}
|
||||
|
||||
/** Absolute camera mark for a (size, target): position + look point. */
|
||||
function placeMark(staging, size, target, jump, rng, progress = 0.5, gaze = null, roomSide = 1) {
|
||||
const clampTo = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
|
||||
const { box, dolly } = staging;
|
||||
// Room visits alternate sides so consecutive room views face opposite
|
||||
// walls (the screen slides left, then right) instead of both staring down
|
||||
// the middle. Screen visits stay centred — the screen IS the axis.
|
||||
let anchor = target === 'screen' ? staging.screen : staging.roomMid;
|
||||
if (target === 'room') {
|
||||
// ONE side per video (threaded in as roomSide): alternating walls is a
|
||||
// zigzag (left-right-left reads as churn, measured flat), while one
|
||||
// steady side gives the look path a consistent off-axis home it keeps
|
||||
// returning to. Across videos the side varies by seed.
|
||||
const side = roomSide || 1;
|
||||
anchor = [side * (staging.roomSide || 2.5), staging.roomMid[1], staging.roomMid[2]];
|
||||
}
|
||||
|
||||
// Dolly centre marches wide→close across the video (eased: linger wide
|
||||
// early, arrive late); the shot size steps around it so cuts still read
|
||||
// while far-apart probes are irreversibly different scales. Lateral
|
||||
// position alternates sides per visit (seeded) — cross the room, don't
|
||||
// orbit one side. A jump takes its arrangement side.
|
||||
const pe = progress * progress * (3 - 2 * progress);
|
||||
const centreZ = dolly.wide + (dolly.close - dolly.wide) * (0.25 + 0.75 * pe);
|
||||
const sizeOff = { wide: 0.9, normal: 0, close: -0.9 }[size] ?? 0;
|
||||
const z = centreZ + sizeOff + rng.range(-0.4, 0.4);
|
||||
let x;
|
||||
if (jump) {
|
||||
const h = (jump.arrangement * 0x9e3779b9) >>> 0;
|
||||
x = (h % 2 === 0 ? 1 : -1) * box.x * 0.55;
|
||||
} else {
|
||||
const side = rng.bool(0.5) ? 1 : -1;
|
||||
x = side * box.x * rng.range(0.25, 0.65);
|
||||
}
|
||||
// Close on the screen stands nearly centre (it fills the frame anyway);
|
||||
// wides take the sides (parallax over the furniture is the point).
|
||||
const cx = size === 'close' && target === 'screen' ? x * 0.3 : x;
|
||||
// A fraction of the viz gaze relocation: both cameras cut the same way.
|
||||
// Kept small (a third) — full coupling threw the room around per section.
|
||||
const gx = gaze ? gaze[0] * 0.35 : 0;
|
||||
const gy = gaze ? gaze[1] * 0.2 : 0;
|
||||
const pos = [
|
||||
clampTo(cx + gx, -box.x, box.x),
|
||||
rng.range(box.yLo, box.yHi) + gy,
|
||||
clampTo(z, box.zLo, box.zHi),
|
||||
];
|
||||
// Look commits to the motive anchor — per target, not one lean for both.
|
||||
// Screen visits lean 0.55 (the screen shifts in frame but stays the
|
||||
// subject); room visits lean 0.9, nearly full (the screen slides out and
|
||||
// the frame becomes wall + furniture — a genuinely different composition).
|
||||
// A single lean for both kept every probe as "screen + room", which
|
||||
// brightness-normalization reduces to structural twins (measured: 28% vs
|
||||
// 68% painted scoring 0.10 apart). NO rng offsets on the anchors:
|
||||
// per-mark jitter is a random walk the descriptor reads as churn.
|
||||
// Consecutive anchors must differ ONLY by motive, never by noise.
|
||||
const screenAnchor = staging.screen;
|
||||
const forward = [0, 0.3, screenAnchor[2]];
|
||||
const k = target === 'room' ? 0.9 : 0.55;
|
||||
const look = [
|
||||
lerp(forward[0], anchor[0], k),
|
||||
lerp(forward[1], anchor[1], k),
|
||||
lerp(forward[2], anchor[2], k),
|
||||
];
|
||||
return { pos, look };
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate the plan at a frame: travel from the previous mark to this cue's
|
||||
* across most of the cue, not just the dissolve.
|
||||
*
|
||||
* Easing only over the dissolve leaves the camera HOLDING a well-composed shot
|
||||
* for the rest of every cue — and held compositions all look alike to a
|
||||
* structural descriptor (measured: six held screen views 0.06-0.13 apart, flat
|
||||
* direction). Travelling instead — always on the way to the next mark — keeps
|
||||
* the composition shifting progressively, which is what direction measures.
|
||||
* Arrives at 80% of the cue so cuts land on a readable frame, not mid-glide.
|
||||
* Jump cues cut instantly (the furniture swapped; gliding through the old
|
||||
* layout is the glitch). Pure in (marks, frame): seeks agree with playback.
|
||||
*/
|
||||
/** Dolly factor per room shot size: a close stands nearer, a wide further. */
|
||||
export const VENUE_SIZE_DOLLY = { wide: 1.3, normal: 1.0, close: 0.72 };
|
||||
|
||||
export function venueShotAt(marks, cueIndex, frame, cueStartFrame, cueEndFrame = null, progress = null) {
|
||||
// Marks are per-cue (size/target constant within a section, pos/look dense).
|
||||
const cur = marks[cueIndex] || marks[marks.length - 1];
|
||||
if (!cur) return null;
|
||||
const prev = cueIndex > 0 ? marks[cueIndex - 1] : null;
|
||||
// The LOOK travels a continuous path through every mark anchor in order,
|
||||
// driven by video progress — never by cue. Per-cue look jumps (screen→
|
||||
// furniture→room) read as churn to the descriptor even when they read as
|
||||
// coverage to an eye: adjacent probes land on different anchors (big gaps)
|
||||
// while far probes converge (mirrored compositions). A slow path through
|
||||
// the same anchors visits everything AND accumulates. Only story jumps cut
|
||||
// the look (the furniture swapped; the path would glide through the old
|
||||
// room). The reported target stays the cue's (HUD motive), the look itself
|
||||
// is where the path is.
|
||||
const look = lookPathAt(marks, progress, cur.jump !== null ? cur : null);
|
||||
if (!prev || cur.jump !== null) {
|
||||
return {
|
||||
pos: cur.pos.slice(), look,
|
||||
size: cur.size, target: cur.target,
|
||||
dolly: VENUE_SIZE_DOLLY[cur.size] ?? 1.0,
|
||||
};
|
||||
}
|
||||
// The travel OUTRUNS the cue (1.25x): the camera is always on the way to
|
||||
// the next mark and never holds a composition — the gaze `drift` grammar.
|
||||
// Held compositions all looked alike (flat direction); eased-then-held
|
||||
// moves made adjacent probes as far apart as distant ones (pan cuts read
|
||||
// as churn to the descriptor). Travel-portion differences are small up
|
||||
// close and accumulate far away, which is exactly the gradient direction
|
||||
// measures. Jump cues still cut instantly (furniture swapped).
|
||||
const span = Math.max(1, (cueEndFrame ?? (cueStartFrame + cur.easeFrames)) - cueStartFrame);
|
||||
const t = clamp01((frame - cueStartFrame) / Math.max(1, span * 1.25));
|
||||
const e = t * t * (3 - 2 * t);
|
||||
return {
|
||||
pos: [lerp(prev.pos[0], cur.pos[0], e), lerp(prev.pos[1], cur.pos[1], e), lerp(prev.pos[2], cur.pos[2], e)],
|
||||
look,
|
||||
size: t < 1 ? `${prev.size}→${cur.size}` : cur.size,
|
||||
target: cur.target,
|
||||
dolly: lerp(VENUE_SIZE_DOLLY[prev.size] ?? 1.0, VENUE_SIZE_DOLLY[cur.size] ?? 1.0, e),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuous look path: ONE steady sweep across the video, plus local motive.
|
||||
*
|
||||
* Interpolating anchors in plan order zigzags (screen-room-screen reads as
|
||||
* churn: binary targets scattered in time can never correlate with time gap,
|
||||
* so direction flats no matter the amplitude). Instead the look sweeps
|
||||
* monotonically — left to right (seeded), rising slightly — while each mark's
|
||||
* anchor pulls a quarter toward the motive. Far probes always differ (the
|
||||
* sweep guarantees it); near probes share direction; the motive textures the
|
||||
* sweep without breaking it. Pure in (marks, progress).
|
||||
*/
|
||||
function lookPathAt(marks, progress, jumpMark) {
|
||||
if (jumpMark) return jumpMark.look.slice();
|
||||
if (!marks.length) return [0, 0.3, -5];
|
||||
const p = clamp01(progress ?? 0.5);
|
||||
// Sweep direction from the plan content (deterministic, varies per video):
|
||||
// hash the first mark's position so two videos sweep different ways.
|
||||
const h = marks.length ? (Math.abs(marks[0].pos[0] * 997 + marks[0].pos[1] * 613) | 0) : 0;
|
||||
const dir = (h % 2 === 0) ? 1 : -1;
|
||||
const sweepX = (p - 0.5) * 2 * 1.6 * dir;
|
||||
const sweepY = 0.15 + p * 0.45;
|
||||
// Nearest mark's anchor for the motive quarter.
|
||||
const q = p * (marks.length - 1);
|
||||
const i = Math.min(marks.length - 1, Math.round(q));
|
||||
const a = marks[i].look;
|
||||
// The sweep is the look; the anchor bends it a quarter toward the motive.
|
||||
// Depth stays on the screen plane so the screen never leaves focus depth.
|
||||
return [
|
||||
sweepX * 0.75 + a[0] * 0.25,
|
||||
sweepY * 0.75 + a[1] * 0.25,
|
||||
a[2],
|
||||
];
|
||||
}
|
||||
|
||||
/** One line for the HUD. */
|
||||
export function describeVenueShot(mark) {
|
||||
if (!mark) return 'room: —';
|
||||
return `room: ${mark.size} on ${mark.target}`;
|
||||
}
|
||||
+32
-1
@@ -52,8 +52,37 @@ const dom = {
|
||||
exportWarnDontAsk: document.getElementById('ew-dontask'),
|
||||
};
|
||||
|
||||
// Show owns the single WebGL2 context. three@0.181 throws when the browser
|
||||
// blocks context creation (WebGL2 disabled, headless shell without
|
||||
// SwiftShader) — historically a blank page, since this runs at module scope.
|
||||
// Fail loudly into the boot-error box instead, with the cause and the fix.
|
||||
function showBootError(detail) {
|
||||
const box = document.getElementById('boot-error');
|
||||
const pre = document.getElementById('be-detail');
|
||||
if (pre) pre.textContent = detail;
|
||||
if (box) box.hidden = false;
|
||||
}
|
||||
|
||||
function bootShow() {
|
||||
try {
|
||||
return new Show({ canvas: dom.canvas, width: 1280, height: 720 });
|
||||
} catch (err) {
|
||||
const msg = err && err.message ? err.message : String(err);
|
||||
showBootError(
|
||||
'The WebGL renderer could not start, so nothing on this page can run — ' +
|
||||
'every preview, gallery and check renders through the same context.\n\n' +
|
||||
msg,
|
||||
);
|
||||
// Tell the boot guard the startup story is over (it would otherwise
|
||||
// report its own generic text, clobbering the specific cause above),
|
||||
// then stop: a half-running app with no renderer is worse than none.
|
||||
window.__FLOW_STATE_READY__ = 'failed';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const state = {
|
||||
show: new Show({ canvas: dom.canvas, width: 1280, height: 720 }),
|
||||
show: bootShow(),
|
||||
playing: false,
|
||||
quality: 'full',
|
||||
loopSection: -1,
|
||||
@@ -376,6 +405,7 @@ function renderPanel() {
|
||||
<div class="kv"><span>tempo conf.</span><b>${show.track.tempo.confidence.toFixed(2)}</b></div>
|
||||
<div class="kv"><span>duration</span><b>${formatTime(show.duration)}</b></div>
|
||||
<div class="kv"><span>sections</span><b>${show.track.sections.length}</b></div>
|
||||
<div class="kv"><span>venue</span><b>${show.look.venue ? `${show.look.venue.name} (${show.look.venue.archetype})${show.look.venueEnabled ? '' : ' · off'}` : 'off'}${show.arc && show.arc.state && show.arc.state.venueState ? ` · light ${show.arc.state.venueState.light.toFixed(2)} fog ${show.arc.state.venueState.fog.toFixed(2)} pop ${show.arc.state.venueState.popMul.toFixed(2)}` : ''}</b> <a href="#" id="link-venue-toggle" title="Toggle venue (ground) — ?venue=1 enables for this page">${show.look.venueEnabled ? 'disable' : 'enable'}</a> · <a href="/venue-gallery.html" target="_blank">gallery</a></div>
|
||||
<div class="kv"><span>director</span><b>${show.look.director}${plan ? ` · ${plan.progression}` : ''}</b></div>
|
||||
<div class="kv"><span>built on</span><b>${personality.signature.join(' + ') || 'nothing'}</b></div>
|
||||
<div class="kv"><span>form</span><b>${personality.shape.sides || 'round'}${
|
||||
@@ -413,6 +443,7 @@ function renderPanel() {
|
||||
<div class="hint">Mixes clicks onto the detected beat grid. If they don't sit on
|
||||
the beat, tempo detection is wrong and everything downstream inherits it.</div>`;
|
||||
document.getElementById('btn-metronome').addEventListener('click', downloadClickTrack);
|
||||
document.getElementById('link-venue-toggle')?.addEventListener('click', (e)=>{ e.preventDefault(); show.setVenueEnabled(!show.look.venueEnabled); renderPanel(); show.seek(show.timeline.frame, {warmup:false}); });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -321,6 +321,12 @@ export function validateModule(module) {
|
||||
if (module.readsDepth && typeof module.readsDepth !== 'boolean') {
|
||||
errors.push(`${id}: \`readsDepth\` must be boolean`);
|
||||
}
|
||||
if (module.venue && typeof module.venue !== 'boolean') {
|
||||
errors.push(`${id}: \`venue\` must be boolean`);
|
||||
}
|
||||
if (module.archetype && !['chamber','stage','ride','passage','void'].includes(module.archetype)) {
|
||||
errors.push(`${id}: unknown venue archetype '${module.archetype}'`);
|
||||
}
|
||||
}
|
||||
|
||||
const params = module.params || {};
|
||||
|
||||
@@ -1475,7 +1475,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"Moiré Grid": {
|
||||
"Moir\u00e9 Grid": {
|
||||
"coverage": 0.327,
|
||||
"bedCoverage": 0.02,
|
||||
"bedCoverageMean": 0.369,
|
||||
@@ -2451,128 +2451,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"Pylon Grid": {
|
||||
"coverage": 0.268,
|
||||
"bedCoverage": 0.12,
|
||||
"bedCoverageMean": 0.256,
|
||||
"variety": 0.125,
|
||||
"blocks": {
|
||||
"scale": 0.011,
|
||||
"orient": 0.12,
|
||||
"layout": 0.105,
|
||||
"region": 0.336,
|
||||
"texture": 0.104,
|
||||
"motion": 0.074,
|
||||
"colour": 0.42
|
||||
},
|
||||
"readsHistory": false,
|
||||
"profile": {
|
||||
"scale": [
|
||||
0.1378,
|
||||
0.128,
|
||||
0.177,
|
||||
0.2494,
|
||||
0.3078
|
||||
],
|
||||
"orient": [
|
||||
0.0919,
|
||||
0.0879,
|
||||
0.0573,
|
||||
0.0629,
|
||||
0.0473,
|
||||
0.0483
|
||||
],
|
||||
"layout": [
|
||||
0.0369,
|
||||
0.0666,
|
||||
0.0609,
|
||||
0.0395,
|
||||
0.0947,
|
||||
0.1125,
|
||||
0.1056,
|
||||
0.0906,
|
||||
0.0531,
|
||||
0.1186,
|
||||
0.1214,
|
||||
0.0614,
|
||||
0.0092,
|
||||
0.0106,
|
||||
0.0114,
|
||||
0.0069
|
||||
],
|
||||
"region": [
|
||||
-0.2065,
|
||||
0.0554,
|
||||
0.122,
|
||||
0.2793,
|
||||
0.1446,
|
||||
0.85,
|
||||
-0.1406,
|
||||
0.0724,
|
||||
0.3754,
|
||||
0.6814,
|
||||
0.0554,
|
||||
0.2293,
|
||||
0.729,
|
||||
-0.082,
|
||||
0.1908,
|
||||
0.6476,
|
||||
0.0481,
|
||||
0.1343,
|
||||
-0.8264,
|
||||
-0.0064,
|
||||
-0.4491,
|
||||
-0.3436,
|
||||
-0.2993,
|
||||
-0.5977,
|
||||
-0.8201,
|
||||
0.0117,
|
||||
-0.855
|
||||
],
|
||||
"texture": [
|
||||
0.5922,
|
||||
0.1031,
|
||||
0.1725,
|
||||
0.0473,
|
||||
0.2784,
|
||||
0.3096,
|
||||
0.0747
|
||||
],
|
||||
"colour": [
|
||||
0.0767,
|
||||
0.0975,
|
||||
0.51,
|
||||
0.2914,
|
||||
0.0217,
|
||||
0.0028,
|
||||
0.4379,
|
||||
0.1787
|
||||
],
|
||||
"motion": [
|
||||
0.277,
|
||||
0.1918,
|
||||
0.1913,
|
||||
0.1674,
|
||||
0.1725,
|
||||
0.0251,
|
||||
0.0558,
|
||||
0.0504,
|
||||
0.0386,
|
||||
0.0959,
|
||||
0.0998,
|
||||
0.1014,
|
||||
0.1035,
|
||||
0.0794,
|
||||
0.1133,
|
||||
0.1199,
|
||||
0.0691,
|
||||
0.0057,
|
||||
0.0154,
|
||||
0.0189,
|
||||
0.008
|
||||
]
|
||||
}
|
||||
},
|
||||
"Prism Bloom": {
|
||||
"coverage": 0.085,
|
||||
"bedCoverage": 0.013,
|
||||
@@ -5257,7 +5135,7 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"Kármán Street": {
|
||||
"K\u00e1rm\u00e1n Street": {
|
||||
"coverage": 0.258,
|
||||
"bedCoverage": 0.047,
|
||||
"bedCoverageMean": 0.269,
|
||||
@@ -8550,7 +8428,7 @@
|
||||
0
|
||||
]
|
||||
},
|
||||
"error": "renders nothing — luminance 0.0012, variance 0.0057. A failed shader compile scores 0.000 on every block; check the console for GLSL errors."
|
||||
"error": "renders nothing \u2014 luminance 0.0012, variance 0.0057. A failed shader compile scores 0.000 on every block; check the console for GLSL errors."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ import { neonCity } from './shader/neon-city.js';
|
||||
import { flora } from './shader/flora.js';
|
||||
import { fireflyDrift } from './shader/firefly-drift.js';
|
||||
import { silkRibbon } from './shader/silk-ribbon.js';
|
||||
import { pylonGrid } from './shader/pylon-grid.js';
|
||||
|
||||
import { prismBloom } from './shader/prism-bloom.js';
|
||||
import { pitchShatter } from './shader/pitch-shatter.js';
|
||||
import { auroraVeil } from './shader/aurora-veil.js';
|
||||
@@ -114,7 +114,6 @@ const MODULES = [
|
||||
flora,
|
||||
fireflyDrift,
|
||||
silkRibbon,
|
||||
pylonGrid,
|
||||
prismBloom,
|
||||
pitchShatter,
|
||||
|
||||
|
||||
@@ -16,7 +16,11 @@ export const flora = {
|
||||
traits: ['shape', 'camera', 'style'],
|
||||
|
||||
params: {
|
||||
count: { type: 'float', range: [1, 5], default: 3, uniform: 'u_count', bias: 'density' },
|
||||
// Cost note: every petal and bloom calls castForm (full SDF assembly),
|
||||
// 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' },
|
||||
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' },
|
||||
@@ -62,7 +66,7 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
// Soft vertical wash behind the plants.
|
||||
vec3 col = mix(pal(0) * 0.03, pal(1) * 0.06, sat((p.y + 1.0) * 0.5));
|
||||
|
||||
for (int i = 0; i < 5; i++) {
|
||||
for (int i = 0; i < 4; i++) {
|
||||
if (i >= int(u_count)) break;
|
||||
float fi = float(i);
|
||||
float s = u_seed + fi * 43.7;
|
||||
@@ -72,8 +76,8 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
|
||||
// Stem, sampled so the bend follows the sway curve rather than a chord.
|
||||
float stemMask = 0.0;
|
||||
for (int j = 0; j < 12; j++) {
|
||||
float hj = (float(j) + 0.5) / 12.0;
|
||||
for (int j = 0; j < 8; j++) {
|
||||
float hj = (float(j) + 0.5) / 8.0;
|
||||
float cx = baseX + swayAt(hj, s, t) * u_sway;
|
||||
float cy = baseY + hj * height;
|
||||
float d = length(p - vec2(cx, cy));
|
||||
@@ -87,9 +91,9 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
float tipY = baseY + height;
|
||||
|
||||
// Petals up the upper half, alternating sides.
|
||||
for (int l = 0; l < 6; l++) {
|
||||
for (int l = 0; l < 4; l++) {
|
||||
float fll = float(l);
|
||||
float node = 0.45 + floor(fll * 0.5) * 0.12;
|
||||
float node = 0.45 + floor(fll * 0.5) * 0.18;
|
||||
float side = fract(fll * 0.5) < 0.5 ? -1.0 : 1.0;
|
||||
float nx = baseX + swayAt(node, s, t) * u_sway;
|
||||
float ny = baseY + node * height;
|
||||
@@ -103,8 +107,8 @@ vec4 scene(vec2 uv, vec2 p) {
|
||||
|
||||
// Bloom: a small corona of signature forms around the crown.
|
||||
float bloom = 0.0;
|
||||
for (int k = 0; k < 6; k++) {
|
||||
float a = 6.2831853 * (float(k) + 0.5) / 6.0 + s;
|
||||
for (int k = 0; k < 4; k++) {
|
||||
float a = 6.2831853 * (float(k) + 0.5) / 4.0 + s;
|
||||
vec2 off = vec2(cos(a), sin(a)) * u_leaf * (0.30 + 0.2 * beat);
|
||||
bloom += castForm(p - vec2(tipX, tipY), off, u_leaf * 0.5);
|
||||
}
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
// Structural family: a field of standing pylons receding to the track's
|
||||
// horizon. The columns converge on a vanishing point, giving real perspective
|
||||
// depth, and each column carries the song's own object on top, so a hexagonal
|
||||
// track grows hexagonal pylons. The beat walks a pulse of light down the rows
|
||||
// rather than flashing the whole frame.
|
||||
//
|
||||
// Nothing here is the scene's own invention any more. The crown is the song's
|
||||
// protagonist and the LEG is a stack of its chorus: a column of bodies holding
|
||||
// the subject up, rather than the thin fixed-width strut this drew for years.
|
||||
// That strut was the same object in every video — a scene that has migrated its
|
||||
// subject and kept its structure is still telling you what IT is, in the half of
|
||||
// the frame that is structure, and next to a marched crown it read as scaffolding
|
||||
// left up around a finished building.
|
||||
//
|
||||
// The crowns are SOLID. This scene already had the one thing the 3D cast needs
|
||||
// and most scenes lack — a real depth axis, with near rows large and far rows
|
||||
// small — so a flat crown was the only part of it drawn without perspective: a
|
||||
// sticker facing the viewer on top of a receding structure. Marching them puts
|
||||
// the object in the same space as the field it stands in, and because each
|
||||
// pylon turns its own crown at its own angle, one field shows the song's form
|
||||
// from a dozen sides at once. See castSolid.
|
||||
//
|
||||
// WHAT THE NUMBERS SAY, since two of them disagree and both are right.
|
||||
//
|
||||
// Crop to the bottom third of the frame — the near rows, where the columns are
|
||||
// tall and the crowns are not — and measure how far apart six identities are:
|
||||
// 13.4/255 with the old strut, 22.2/255 with the chorus stacked. That is the
|
||||
// complaint answered. The strut was a fixed width and a fixed shape, so the only
|
||||
// thing separating one video's structure from another's was the palette.
|
||||
//
|
||||
// The whole-frame variety score barely moves for it: 0.1322 to 0.1348 over
|
||||
// 8 songs x 8 seeds. That is honest rather than disappointing — the crowns
|
||||
// already dominate a frame-level descriptor, and a measurement averaged over the
|
||||
// whole image cannot see a change confined to the lower third. It is the reason
|
||||
// the crop above exists.
|
||||
//
|
||||
// The frame score is also still under the flat version's 0.141, and the cause is
|
||||
// known: a body always fills its own silhouette, where a stamped cast form paints
|
||||
// anywhere between an outline and a disc depending on whether the identity is
|
||||
// hollow, and that swing was worth three hundredths of LAYOUT variety. Two
|
||||
// candidate fixes were tried and both measured flat — keeping the far rows
|
||||
// stamped (0.132), and running the lit surface through inkPattern (0.132 here,
|
||||
// and 0.0009 on Effigy). It is the solid itself, and the trade is deliberate:
|
||||
// the score does not measure a crown that ignores the perspective of the field
|
||||
// it stands in, or a leg that is the same object in every video.
|
||||
|
||||
export const pylonGrid = {
|
||||
name: 'Pylon Grid',
|
||||
family: 'structural',
|
||||
kind: 'fragment',
|
||||
// Personality: see look/Personality.js.
|
||||
// Takes the track's surface grain, but lightly — this is drawn, not filmed.
|
||||
texture: 0.4,
|
||||
consumes: ['form', 'ink', 'staging'],
|
||||
traits: ['shape', 'space', 'style'],
|
||||
|
||||
params: {
|
||||
columns: { type: 'float', range: [3, 16], default: 8, uniform: 'u_columns', bias: 'density' },
|
||||
rows: { type: 'float', range: [2, 10], default: 6, uniform: 'u_rows', bias: 'density' },
|
||||
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' },
|
||||
speed: { type: 'float', range: [0.05, 0.8], default: 0.3, uniform: 'u_speed', bias: 'motion', rate: true },
|
||||
palette: { type: 'palette', count: 4 },
|
||||
},
|
||||
|
||||
reactive: {
|
||||
pulse: { feature: 'beat', amount: 0.4, response: 'smooth' },
|
||||
rows: { feature: 'bandLow', amount: 0.25 },
|
||||
},
|
||||
|
||||
shader: `
|
||||
vec4 scene(vec2 uv, vec2 p) {
|
||||
float t = u_time * u_speed + u_seed;
|
||||
float hy = sigHorizonY();
|
||||
vec3 col = pal(0) * 0.05;
|
||||
|
||||
// Whether a crown already owns this pixel. Rows are walked near to far, so
|
||||
// first-wins is the correct occlusion here rather than merely the cheap one
|
||||
// — and it bounds a hundred and sixty instances to about one march.
|
||||
float painted = 0.0;
|
||||
|
||||
for (int r = 0; r < 10; r++) {
|
||||
if (float(r) >= u_rows) break;
|
||||
float fr = float(r);
|
||||
|
||||
// Perspective: the last row sits on the horizon, the first at the bottom
|
||||
// of the frame, everything between scaling by its depth fraction.
|
||||
float f = fr / max(u_rows - 1.0, 1.0); // 0 near .. 1 far
|
||||
float y0 = mix(-1.08, hy + 0.06, f); // row baseline
|
||||
float scale = mix(1.0, 0.12, f); // perspective shrink
|
||||
float depthShade = mix(1.0, 0.35, f); // far rows dim
|
||||
|
||||
for (int c = 0; c < 16; c++) {
|
||||
if (float(c) >= u_columns) break;
|
||||
float fc = float(c);
|
||||
float cx = (fc / max(u_columns - 1.0, 1.0) - 0.5) * 2.0 * u_spread * scale;
|
||||
|
||||
float h = u_height * scale;
|
||||
float yTop = y0 + h;
|
||||
|
||||
// The beat pulse walks a glow down the depth axis, row by row, so the
|
||||
// light travels rather than strobing the whole frame.
|
||||
float walk = sat(1.0 - abs(fr - mod(u_beatPhase * 6.0 + t * 0.4, u_rows)));
|
||||
float inten = (0.35 + 0.65 * walk) * depthShade * (0.7 + 0.3 * u_beat);
|
||||
|
||||
// The crown is resolved BEFORE the leg, because the two overlap
|
||||
// where the object sits on the column and the crown has to win it —
|
||||
// resolved the other way round, every subject is cut off at the
|
||||
// waist by the thing holding it up.
|
||||
//
|
||||
// The song's object, standing on the pylon.
|
||||
vec2 crown = vec2(cx, yTop);
|
||||
float size = (0.06 + 0.2 * scale) * (1.0 + u_beat * 0.4);
|
||||
vec2 local = (p - crown) / max(size, 1e-3);
|
||||
|
||||
if (dot(local, local) < 1.6 && painted < 0.5) {
|
||||
// Each crown at its own angle, so the field is one form seen
|
||||
// from many sides rather than one silhouette repeated. The turn
|
||||
// is slow and per-pylon; the row term keeps a receding line of
|
||||
// them from presenting the same profile all the way back.
|
||||
mat3 turn = castTurn(t * 0.6 + fc * 0.9 + fr * 0.4, 0.2 + sin(t + fr) * 0.2);
|
||||
vec3 n;
|
||||
float hit = castSolid(local, turn, n);
|
||||
if (hit > 0.0) {
|
||||
painted = 1.0;
|
||||
col = mix(col, castLit(n, vec3(0.0, 0.0, 1.0)) * inten * (0.6 + u_pulse), 0.92);
|
||||
}
|
||||
// Its outline in the track's line weight, which is what carries
|
||||
// the far rows: at twelve percent scale a crown is a few pixels
|
||||
// and the lit faces are gone before the edge is.
|
||||
float d = castSDF3(turn * vec3(local, 0.0)) * size;
|
||||
col += pal(3) * inkStroke(d) * inten * (0.5 + u_pulse);
|
||||
}
|
||||
col += pal(int(mod(fr, 4.0))) * exp(-dot(p - crown, p - crown) * 60.0) * u_pulse * depthShade;
|
||||
|
||||
// THE LEG: the chorus, stacked. How many members make up a column
|
||||
// is the song's element size deciding it — a track built of a few
|
||||
// huge forms stands on three of them, a track of many small ones on
|
||||
// a ladder of nine — and how WIDE the column is comes from the
|
||||
// chorus's own proportions, which is why no two songs get the same
|
||||
// pylon. See Identity.js: this is what the chorus solid is for.
|
||||
float segs = clamp(floor(2.0 + 3.6 / max(stageScale(), 0.3)), 2.0, 9.0);
|
||||
float member = max(h / (segs * 2.0), 1e-3);
|
||||
|
||||
// The bounding test is the column's own box, so a pixel outside it
|
||||
// costs two comparisons rather than a march. With a hundred and
|
||||
// sixty pylons on screen that test is most of the scene's budget.
|
||||
if (abs(p.x - cx) < member * 1.3 && p.y > y0 - member * 0.3
|
||||
&& p.y < yTop + member * 0.3 && painted < 0.5) {
|
||||
float rung = floor((p.y - y0) / (member * 2.0));
|
||||
float centreY = y0 + (rung + 0.5) * member * 2.0;
|
||||
vec2 legLocal = vec2(p.x - cx, p.y - centreY) / member;
|
||||
|
||||
// Every member at its own angle, so a column is a stack of one
|
||||
// form seen many ways rather than one form printed nine times.
|
||||
mat3 legTurn = castTurn(t * 0.35 + rung * 1.1 + fc * 0.5, rung * 0.5);
|
||||
vec3 ln;
|
||||
float legHit = castChorusSolid(legLocal, legTurn, ln);
|
||||
if (legHit > 0.0) {
|
||||
painted = 1.0;
|
||||
col = mix(col, castLit(ln, vec3(0.0, 0.0, 1.0))
|
||||
* mix(inten, 1.0, 0.25) * pal(int(mod(fc, 4.0))) * 1.6, 0.9);
|
||||
}
|
||||
col += pal(int(mod(fc, 4.0)))
|
||||
* inkStroke(castChorus3(legTurn * vec3(legLocal, 0.0)) * member) * inten * 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Ground haze and the shared air between here and the horizon.
|
||||
col = sigAir(col, p, smoothstep(0.0, 1.6, abs(p.y - hy)));
|
||||
return vec4(inkValue(col), 1.0);
|
||||
}
|
||||
`,
|
||||
};
|
||||
@@ -1,12 +1,9 @@
|
||||
// STAGE: Pylon Field 3D — the song's chorus as structure, with depth.
|
||||
//
|
||||
// The flat pylon-grid stamps castSolid/castChorusSolid impostors on a screen-space
|
||||
// perspective grid (pylon-grid.js:83-168). This is the mesh twin: the same
|
||||
// rows×columns grid, the same chorus-stacked legs (segs from stageScale) and the
|
||||
// same beat-walk pulse — now as BufferGeometry with real occlusion, parallax and
|
||||
// scale foreshortening. Crowns are the protagonist assembly via actorToGeometry,
|
||||
// legs are chorus rungs, ground is a plane at sigHorizonY level. The palette and
|
||||
// lattice are the same data the flat stage consumes.
|
||||
// A rows×columns grid of chorus-stacked legs (segs from stageScale) with a
|
||||
// beat-walk pulse — as BufferGeometry with real occlusion, parallax and scale
|
||||
// foreshortening. Crowns are the protagonist assembly via actorToGeometry,
|
||||
// legs are chorus rungs, ground is a plane at sigHorizonY level.
|
||||
//
|
||||
// Analytic motion only: f(t,seed,params,actor.motion). No integration, so seek ===
|
||||
// playback. Camera is the shared rig (Compositor.sharedCamera) driven from
|
||||
|
||||
@@ -132,7 +132,19 @@ const ROW = (module) => (metadata.scenes && metadata.scenes[module.name]) || nul
|
||||
*/
|
||||
const ASSUMED_COVERAGE = 0.15;
|
||||
|
||||
export function isVenue(module) {
|
||||
return !!(module && module.venue);
|
||||
}
|
||||
|
||||
export function venueCoverage(module) {
|
||||
// Venues are room/stage planes that fill the frame by construction.
|
||||
// Hard-coded so they pass GROUND_MIN without a gallery measurement.
|
||||
if (!isVenue(module)) return coverageOf(module);
|
||||
return 0.72;
|
||||
}
|
||||
|
||||
export function coverageOf(module) {
|
||||
if (isVenue(module)) return 0.72;
|
||||
const row = ROW(module);
|
||||
return row ? row.coverage : ASSUMED_COVERAGE;
|
||||
}
|
||||
@@ -143,6 +155,7 @@ export function coverageOf(module) {
|
||||
* asks. See GROUND_BIAS for why the two numbers differ so much.
|
||||
*/
|
||||
export function groundCoverageOf(module) {
|
||||
if (isVenue(module)) return 0.72;
|
||||
const row = ROW(module);
|
||||
if (!row) return ASSUMED_COVERAGE;
|
||||
if (row.bedCoverageMean !== undefined) return row.bedCoverageMean;
|
||||
@@ -163,6 +176,7 @@ export function groundCoverageOf(module) {
|
||||
* an occasional dim intro. So: fills the frame on average, and never vanishes.
|
||||
*/
|
||||
export function groundFloorCoverageOf(module) {
|
||||
if (isVenue(module)) return 0.62;
|
||||
const row = ROW(module);
|
||||
if (!row) return ASSUMED_COVERAGE;
|
||||
return row.bedCoverage === undefined ? groundCoverageOf(module) : row.bedCoverage;
|
||||
@@ -191,6 +205,8 @@ export function surfaceOf(module) {
|
||||
* tolerance of 4.
|
||||
*/
|
||||
export function canGround(module) {
|
||||
if (isVenue(module)) return false;
|
||||
if (module && module.readsDepth) return false;
|
||||
return isMeasured(module)
|
||||
&& surfaceOf(module) === 'canvas'
|
||||
&& groundCoverageOf(module) >= GROUND_MIN
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
// Pure, no THREE — produces VenueSpec (data, not scene graph).
|
||||
// Same contract as ActorGenerator: audio tilts centre, forked rng picks inside,
|
||||
// never Math.random / wall-clock, seek === playback.
|
||||
|
||||
import { signatureWeight } from '../look/Personality.js';
|
||||
import { venues, ARCHETYPES } from './registry.js';
|
||||
|
||||
const clamp01 = (x) => Math.max(0, Math.min(1, x));
|
||||
|
||||
/**
|
||||
* Generate one venue — the place the track lives.
|
||||
* Venue is higher-level than viz: picked first, viz comes second.
|
||||
* Census-like: registry has traits, picked via signatureWeight.
|
||||
* @param {object} opts.summary FeatureTrack.summary
|
||||
* @param {import('../engine/rng.js').Rng} opts.rng forked rng.fork('venue')
|
||||
* @param {object} [opts.personality] look.personality (signature, shape, space, etc.)
|
||||
* @param {object} [opts.director] look.director
|
||||
* @param {object} [opts.story] look.story
|
||||
* @param {Array} [opts.sections] track.sections (for perSection)
|
||||
* @returns {object} VenueSpec
|
||||
*/
|
||||
export function generateVenue({ summary = {}, rng, personality = null, director = null, story = null, sections = null }) {
|
||||
if (!rng) throw new Error('generateVenue: rng required');
|
||||
|
||||
const signature = personality ? personality.signature : [];
|
||||
// Census pick — weighted by signature just like scenes
|
||||
let weights = venues.map((m) => signatureWeight(m, signature));
|
||||
// Audio tilts archetype centre softly (not a rule): stillness/bpm/flatness → archetype weights handled via trait->signature correlation;
|
||||
// add mild director lean if present
|
||||
const s = summary || {};
|
||||
const stillness = personality && personality.motion ? personality.motion.stillness : 0.5;
|
||||
const fast = clamp01(((s.bpm ?? 120) - 80) / 80);
|
||||
const noisy = Math.min(1, (s.meanFlatness ?? 0.2) * 3);
|
||||
// Sad slow → chamber (stillness high + slow), pumping → stage (fast + noisy),
|
||||
// driving songs → ride (fast tempo + high energy + low stillness: a song
|
||||
// that moves wants a vehicle). Tilt, not decide.
|
||||
const tilt = {};
|
||||
const drive = clamp01(((s.bpm ?? 120) - 100) / 60) * (1 - stillness * 0.7);
|
||||
for (let i = 0; i < venues.length; i++) {
|
||||
const v = venues[i];
|
||||
let w = weights[i];
|
||||
if (v.archetype === 'chamber') w *= 0.7 + stillness * 0.8 + (1 - fast) * 0.35;
|
||||
if (v.archetype === 'stage') w *= 0.6 + fast * 0.6 + noisy * 0.35;
|
||||
if (v.archetype === 'ride') w *= 0.35 + drive * 1.4;
|
||||
tilt[v.name] = w;
|
||||
weights[i] = w;
|
||||
}
|
||||
// Ensure at least one weight > 0
|
||||
const totalW = weights.reduce((a, b) => a + Math.max(0, b), 0);
|
||||
if (totalW <= 0) weights = venues.map(() => 1);
|
||||
|
||||
const picked = rng.pickWeighted(venues, weights);
|
||||
|
||||
const seed = rng.int(0, 0x7fffffff);
|
||||
|
||||
// Shell — kind & scale vary per picked archetype but stay simple in V1
|
||||
const shell = {
|
||||
kind: picked.archetype,
|
||||
scale: 1,
|
||||
wallHeight: picked.archetype === 'chamber' ? 6 : picked.archetype === 'ride' ? 2.4 : 3.2,
|
||||
wallDist: picked.archetype === 'chamber' ? 11 : picked.archetype === 'ride' ? 26 : 20,
|
||||
};
|
||||
|
||||
// Projection — hybrid per archetype. The ride's screen is the car radio:
|
||||
// small, bright, watched up close.
|
||||
const projection = picked.archetype === 'chamber'
|
||||
? { kind: 'window', uvScale: 1, brightness: 0.55 }
|
||||
: picked.archetype === 'ride'
|
||||
? { kind: 'radio', uvScale: 1, brightness: 0.9 }
|
||||
: { kind: 'screen', uvScale: 1, brightness: 0.75 };
|
||||
|
||||
// Population — lattice-like, deterministic count/spread/jitter. The ride
|
||||
// seats actor passengers (2-4), not a crowd.
|
||||
const latticeKinds = ['grid', 'scatter', 'radial'];
|
||||
const pRng = rng.fork(`venue:pop:${picked.name}`);
|
||||
const population = picked.archetype === 'ride'
|
||||
? { kind: 'row', count: pRng.int(2, 4), spread: pRng.range(0.4, 0.6), jitter: pRng.range(0.05, 0.2) }
|
||||
: {
|
||||
kind: pRng.pick(latticeKinds),
|
||||
count: pRng.int(picked.archetype === 'chamber' ? 3 : 4, picked.archetype === 'chamber' ? 9 : 12),
|
||||
spread: pRng.range(0.5, 0.85),
|
||||
jitter: pRng.range(0.15, 0.45),
|
||||
};
|
||||
|
||||
// PaletteMap — which palette entry each kit piece reads (seeded)
|
||||
const paletteMap = [];
|
||||
const mapLen = population.count;
|
||||
for (let i = 0; i < mapLen; i++) paletteMap.push(pRng.int(0, 3));
|
||||
|
||||
// Per-section evolution — discrete, from Story (setup/turn/climax/resolution).
|
||||
// These are the venue's REST values per section: the ArcDriver interpolates
|
||||
// between them across each boundary (see _venueStateAt), so the room drifts
|
||||
// instead of stepping. A section may still JUMP the room — see rearrangeAt
|
||||
// below — but only with a camera cut to cover it.
|
||||
const n = sections ? sections.length : (story && story.sections ? story.sections.length : 0);
|
||||
const perSection = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const state = story && story.sections && story.sections[i] ? story.sections[i] : null;
|
||||
if (!state) {
|
||||
perSection.push({ light: 0.7, fog: 0.35, popMul: 0.65, reachMul: 0.85 });
|
||||
continue;
|
||||
}
|
||||
const act = state.act;
|
||||
const tension = state.tension ?? 0.5;
|
||||
let light = 0.65;
|
||||
let fog = 0.35;
|
||||
let popMul = 0.6;
|
||||
let reachMul = 0.9;
|
||||
if (act === 'climax') { light = 0.92; fog = 0.18; popMul = 1.0; reachMul = 1.2; }
|
||||
else if (act === 'turn') { light = 0.78; fog = 0.28; popMul = 0.85; reachMul = 1.0; }
|
||||
else if (act === 'setup') { light = 0.52; fog = 0.45; popMul = 0.35; reachMul = 0.7; }
|
||||
else if (act === 'resolution') { light = 0.48; fog = 0.55; popMul = 0.45; reachMul = 0.65; }
|
||||
else if (act === 'development') { light = 0.68 + tension * 0.15; fog = 0.32; popMul = 0.6 + tension * 0.2; reachMul = 0.85 + tension * 0.2; }
|
||||
perSection.push({ light: clamp01(light), fog: clamp01(fog), popMul: clamp01(popMul), reachMul: clamp01(reachMul) });
|
||||
}
|
||||
|
||||
// Story rearrangements — the ONLY sanctioned jumps. Decided here at
|
||||
// generation time from the story (never mid-render): when the song moves
|
||||
// to a new act, the room may re-hang itself — new furniture layout, new
|
||||
// camera side — and the camera MUST cut on the same frame to cover it
|
||||
// (see ArcDriver venueJumpAt / Camera.relocatesAt). Furniture never swaps
|
||||
// mid-act: within an act the room only drifts, so objects persist.
|
||||
//
|
||||
// Rationed to ONE re-hang per video (plus the recap return): each jump is
|
||||
// a discontinuity no arc can place, and measured three of them shuffle a
|
||||
// video flat while one reads as an event. The re-hang lands on the
|
||||
// strongest act change (climax preferred); the outro returns to the
|
||||
// intro's arrangement (recap is a return, not a redecoration). Seeded,
|
||||
// stable, serialisable — part of the spec.
|
||||
const rearrangeAt = [];
|
||||
{
|
||||
const acts = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const state = story && story.sections && story.sections[i] ? story.sections[i] : null;
|
||||
acts.push(state ? state.act || 'development' : 'development');
|
||||
}
|
||||
// Rank act changes: climax first, then turn, then any other change.
|
||||
const rank = (a) => a === 'climax' ? 0 : a === 'turn' ? 1 : 2;
|
||||
let hangAt = -1, hangRank = 99;
|
||||
for (let i = 1; i < n; i++) {
|
||||
if (acts[i] === acts[i - 1]) continue;
|
||||
const r = Math.min(rank(acts[i]), rank(acts[i - 1]) + 0.5);
|
||||
if (r < hangRank) { hangRank = r; hangAt = i; }
|
||||
}
|
||||
let arrangement = 0;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const isRecap = story && story.recap
|
||||
&& sections && sections[i] && sections[i].kind === 'outro'
|
||||
&& acts[i] === 'resolution';
|
||||
if (isRecap) arrangement = 0;
|
||||
else if (i === hangAt) arrangement = 1;
|
||||
rearrangeAt.push(arrangement);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
archetype: picked.archetype,
|
||||
name: picked.name,
|
||||
seed,
|
||||
shell,
|
||||
projection,
|
||||
population,
|
||||
paletteMap,
|
||||
perSection,
|
||||
rearrangeAt,
|
||||
venue: true,
|
||||
readsDepth: false,
|
||||
module: picked, // keep registry ref for trait/consumes checks
|
||||
};
|
||||
}
|
||||
|
||||
export function describeVenue(venue) {
|
||||
if (!venue) return 'no venue';
|
||||
return `${venue.name}(${venue.archetype}) pop ${venue.population.count} ${venue.population.kind} · proj ${venue.projection.kind}`;
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
// VENUE: Chamber — derelict room full of memories.
|
||||
//
|
||||
// A sparse, still place. Window-light + floor reflections carry the viz
|
||||
// tint; scattered memories (primitives via actorToGeometry) populate it
|
||||
// via stageNode. Population density + light/fog evolve per Story act
|
||||
// (setup sparse/distant, climax one large memory at centre, resolution dim).
|
||||
// Primitives only in V1 — no glTF kit.
|
||||
|
||||
import { actorToGeometry } from '../actors/meshes.js';
|
||||
|
||||
export const chamber = {
|
||||
name: 'Chamber',
|
||||
archetype: 'chamber',
|
||||
family: 'minimal',
|
||||
kind: 'model',
|
||||
venue: true,
|
||||
consumes: ['form', 'ink', 'staging'],
|
||||
traits: ['shape', 'space', 'style'],
|
||||
texture: 0.35,
|
||||
// How much viz bloom this room can take before its highlights clip: the
|
||||
// chamber is dim plaster and floor light, so it earns the blaze often.
|
||||
reach: 1.1,
|
||||
|
||||
params: {
|
||||
density: { type: 'float', range: [0.2, 0.9], default: 0.45, uniform: 'u_density', bias: 'density' },
|
||||
jitter: { type: 'float', range: [0, 0.6], default: 0.25, uniform: 'u_jitter' },
|
||||
light: { type: 'float', range: [0.3, 1.2], default: 0.7, uniform: 'u_light', bias: 'energy' },
|
||||
palette: { type: 'palette', count: 4 },
|
||||
},
|
||||
|
||||
reactive: {},
|
||||
|
||||
// Venue RTT: the compositor calls this with the finished viz texture after
|
||||
// compositing the viz stack into vizTarget. The window and the floor
|
||||
// reflection sample it, so the viz appears INSIDE the room rather than
|
||||
// composited over it. Texture object identity is stable frame-to-frame, so
|
||||
// assigning map/emissiveMap is cheap; needsUpdate only on first bind.
|
||||
bindVizTexture(instance, texture) {
|
||||
if (!instance || !texture) return;
|
||||
let dirty = false;
|
||||
if (instance.windowMat && instance.windowMat.emissiveMap !== texture) {
|
||||
instance.windowMat.emissiveMap = texture;
|
||||
dirty = true;
|
||||
}
|
||||
// The spill shows the viz through its emissive channel (the room is too
|
||||
// dark for diffuse alone); map rides along so the two never disagree.
|
||||
if (instance.reflectMat && (instance.reflectMat.map !== texture
|
||||
|| instance.reflectMat.emissiveMap !== texture)) {
|
||||
instance.reflectMat.map = texture;
|
||||
instance.reflectMat.emissiveMap = texture;
|
||||
dirty = true;
|
||||
}
|
||||
if (dirty) {
|
||||
if (instance.windowMat) instance.windowMat.needsUpdate = true;
|
||||
if (instance.reflectMat) instance.reflectMat.needsUpdate = true;
|
||||
}
|
||||
},
|
||||
|
||||
// Base wash for the viz composite. The room's own wall colour is too dark
|
||||
// to densify anything (dark song → dark palette → wash ≈ black, and the
|
||||
// 5x section jump stays), so the wash is a glowing screen base instead:
|
||||
// palette-tinted mid grey that arcs with venueState.light. A screen that
|
||||
// glows brighter than its walls is physically sensible, and the wash
|
||||
// carries the slow travel while calming the keyed shot's jumps.
|
||||
// See Layer.washColor.
|
||||
washColor(instance) {
|
||||
const st = (instance && instance.venueState) || null;
|
||||
const light = st ? st.light : 0.7;
|
||||
let r = 0.05, g = 0.05, b = 0.07;
|
||||
if (instance && instance.walls && instance.walls.length) {
|
||||
const c = instance.walls[0].material.color;
|
||||
r = c.r; g = c.g; b = c.b;
|
||||
}
|
||||
const glow = (0.07 + light * 0.09);
|
||||
return [r * 0.5 + glow, g * 0.5 + glow, b * 0.5 + glow * 1.1];
|
||||
},
|
||||
|
||||
build({ scene, seed, params, actorSpec, venueSpec, THREE }) {
|
||||
const T = THREE;
|
||||
// Room shell — 5 planes: floor + 4 walls (no ceiling, open to sky)
|
||||
const room = new T.Group();
|
||||
room.name = 'room';
|
||||
|
||||
const floorMat = new T.MeshStandardMaterial({
|
||||
color: new T.Color(0.14, 0.13, 0.12),
|
||||
roughness: 0.85,
|
||||
metalness: 0.04,
|
||||
});
|
||||
const wallMat = new T.MeshStandardMaterial({
|
||||
color: new T.Color(0.18, 0.17, 0.16),
|
||||
roughness: 0.92,
|
||||
metalness: 0.02,
|
||||
});
|
||||
const floorGeo = new T.PlaneGeometry(22, 22);
|
||||
const floor = new T.Mesh(floorGeo, floorMat);
|
||||
floor.rotation.x = -Math.PI / 2;
|
||||
floor.position.y = -1.4;
|
||||
floor.receiveShadow = false;
|
||||
room.add(floor);
|
||||
|
||||
const wallGeo = new T.PlaneGeometry(22, 6);
|
||||
const walls = [];
|
||||
const wallPositions = [
|
||||
{ pos: [0, 1.6, -11], rotY: 0 },
|
||||
{ pos: [0, 1.6, 11], rotY: Math.PI },
|
||||
{ pos: [-11, 1.6, 0], rotY: Math.PI / 2 },
|
||||
{ pos: [11, 1.6, 0], rotY: -Math.PI / 2 },
|
||||
];
|
||||
for (const w of wallPositions) {
|
||||
const wall = new T.Mesh(wallGeo, wallMat.clone());
|
||||
wall.position.set(w.pos[0], w.pos[1], w.pos[2]);
|
||||
wall.rotation.y = w.rotY;
|
||||
wall.receiveShadow = false;
|
||||
room.add(wall);
|
||||
walls.push(wall);
|
||||
}
|
||||
|
||||
// Window plane — the viz render surface. 16:9 like the viz that
|
||||
// feeds it, so the image arrives undistorted; emissiveMap carries the
|
||||
// viz (bound per-frame by bindVizTexture), emissive color stays the
|
||||
// palette tint multiplier. Sized to fill ~40% of the frame from the
|
||||
// room camera (was 6.4x3.6 ≈ 22% — a postage stamp the section's viz
|
||||
// could not drive the frame through, flattening filmstrip direction).
|
||||
const windowGeo = new T.PlaneGeometry(12, 6.75);
|
||||
const windowMat = new T.MeshStandardMaterial({
|
||||
color: new T.Color(0.02, 0.02, 0.02),
|
||||
emissive: new T.Color(1, 1, 1),
|
||||
emissiveIntensity: 1.0,
|
||||
roughness: 0.6,
|
||||
metalness: 0.02,
|
||||
});
|
||||
const windowMesh = new T.Mesh(windowGeo, windowMat);
|
||||
windowMesh.position.set(0, 1.35, -10.9);
|
||||
windowMesh.name = 'window';
|
||||
room.add(windowMesh);
|
||||
|
||||
// Floor spill of the viz screen. Same 16:9 texture, dimmed and
|
||||
// translucent — reads as light spilling out of the window. It is a
|
||||
// SECOND plane lying on the floor, so every way it differs from the
|
||||
// floor reads as architecture: its rim reads as a raised bar, its face
|
||||
// as a second floor. Four things keep it a light streak instead:
|
||||
// exactly co-planar (no tilt — a 1° tilt over 10 units is a 22 cm ramp
|
||||
// at this grazing angle), feathered on all sides, faded to nothing at
|
||||
// the camera end (canvas bottom = near side) so no near rim ever slides
|
||||
// under the lens, and pulled back toward the window.
|
||||
let spillAlpha = null;
|
||||
try {
|
||||
if (typeof document !== 'undefined' && document.createElement) {
|
||||
const cnv = document.createElement('canvas');
|
||||
cnv.width = 256; cnv.height = 160;
|
||||
const g = cnv.getContext('2d');
|
||||
if (g) {
|
||||
// Along-beam: opaque at the window end (canvas top = far
|
||||
// side), gone at the camera end.
|
||||
const along = g.createLinearGradient(0, 0, 0, 160);
|
||||
along.addColorStop(0, '#fff');
|
||||
along.addColorStop(0.45, '#888');
|
||||
along.addColorStop(0.8, '#111');
|
||||
along.addColorStop(1, '#000');
|
||||
g.fillStyle = along;
|
||||
g.fillRect(0, 0, 256, 160);
|
||||
// Across-beam: kill the left/right rims.
|
||||
g.globalCompositeOperation = 'destination-in';
|
||||
const across = g.createLinearGradient(0, 0, 256, 0);
|
||||
across.addColorStop(0, '#000');
|
||||
across.addColorStop(0.18, '#fff');
|
||||
across.addColorStop(0.82, '#fff');
|
||||
across.addColorStop(1, '#000');
|
||||
g.fillStyle = across;
|
||||
g.fillRect(0, 0, 256, 160);
|
||||
spillAlpha = new T.CanvasTexture(cnv);
|
||||
}
|
||||
}
|
||||
} catch { spillAlpha = null; }
|
||||
const reflectGeo = new T.PlaneGeometry(12, 6);
|
||||
const reflectMat = new T.MeshStandardMaterial({
|
||||
color: new T.Color(0.02, 0.02, 0.03),
|
||||
emissive: new T.Color(1, 1, 1),
|
||||
emissiveIntensity: 0.35,
|
||||
roughness: 0.35,
|
||||
metalness: 0.15,
|
||||
transparent: true,
|
||||
opacity: 0.3,
|
||||
depthWrite: false,
|
||||
polygonOffset: true,
|
||||
polygonOffsetFactor: -1,
|
||||
});
|
||||
if (spillAlpha) reflectMat.alphaMap = spillAlpha;
|
||||
const reflection = new T.Mesh(reflectGeo, reflectMat);
|
||||
reflection.rotation.x = -Math.PI / 2;
|
||||
reflection.position.set(0, -1.398, -4.6);
|
||||
reflection.name = 'reflection';
|
||||
room.add(reflection);
|
||||
|
||||
scene.add(room);
|
||||
|
||||
// Lighting — ambient + key from the camera side. The key used to sit
|
||||
// behind the window wall, leaving every camera-facing surface in
|
||||
// shadow lit only by ambient — the room read as a black void with a
|
||||
// floating screen. Front light keeps a dim room present.
|
||||
const ambient = new T.AmbientLight(0xffffff, 0.42);
|
||||
scene.add(ambient);
|
||||
const sun = new T.DirectionalLight(0xfff6e0, 0.95);
|
||||
sun.position.set(2.5, 4.2, 7.5);
|
||||
sun.castShadow = false;
|
||||
scene.add(sun);
|
||||
const fill = new T.DirectionalLight(0xffffff, 0.22);
|
||||
fill.position.set(-3.2, 2.0, 4.0);
|
||||
scene.add(fill);
|
||||
|
||||
scene.background = null;
|
||||
scene.fog = new T.Fog(0x0e0e12, 12, 28);
|
||||
scene.userData.transparentBackground = true;
|
||||
|
||||
// Population container — memories
|
||||
const popGroup = new T.Group();
|
||||
popGroup.name = 'memories';
|
||||
scene.add(popGroup);
|
||||
|
||||
return {
|
||||
room, floor, floorMat, wallMat, walls, windowMesh, windowMat,
|
||||
reflection, reflectMat,
|
||||
ambient, sun, fill,
|
||||
popGroup,
|
||||
seed,
|
||||
venueSpec: venueSpec || null,
|
||||
builtKey: null,
|
||||
popCount: 0,
|
||||
};
|
||||
},
|
||||
|
||||
update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, venueSpec, venueState, venueArrangement = null, THREE }) {
|
||||
const T = THREE;
|
||||
const t = timeline.time;
|
||||
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 identity = personality ? personality.identity : null;
|
||||
const shape = personality ? personality.shape : null;
|
||||
const space = personality ? personality.space : null;
|
||||
const style = personality ? personality.style : null;
|
||||
|
||||
// Trait reads so lint / scene-gate sees delta
|
||||
const shapeTilt = shape ? shape.tilt : 0;
|
||||
const shapeSides = shape ? shape.sides : 0;
|
||||
const spaceHorizon = space ? space.horizon : 0.5;
|
||||
const styleWeight = style ? style.lineWeight : 0.5;
|
||||
|
||||
const ink = identity ? identity.ink : null;
|
||||
const lattice = identity ? identity.lattice : null;
|
||||
const elementScale = identity ? identity.lattice.elementScale : 0.35;
|
||||
void elementScale; void lattice; void shapeSides;
|
||||
|
||||
// Resolve venueSpec — prefer passed-in, fallback to instance stored
|
||||
const vs = venueSpec || instance.venueSpec;
|
||||
const vState = venueState || instance.venueState || null;
|
||||
const perSectionMul = vs && vs.perSection ? 1 : 1;
|
||||
if (vState) instance.venueState = vState;
|
||||
|
||||
// Ground/personality driven materials. The room MUST read the story arc:
|
||||
// a static dark box dilutes every section change into churn (filmstrip
|
||||
// direction collapsed when the venue arrived). venueState.light swings
|
||||
// ~0.5 (setup) to ~0.92 (climax) — map it onto wall/floor albedo and
|
||||
// the sun so the room breathes with the song, not just the window.
|
||||
const c0 = pal[0];
|
||||
// The venue is the STABLE place — it moves less than the viz, not
|
||||
// more. Full light steps at every section made consecutive sections
|
||||
// ~4x further apart than without a venue (a shuffle, not an arc), so
|
||||
// the room takes half steps; the viz window takes the full ones.
|
||||
//
|
||||
// Walls take their HUE from the palette but their LEVEL from the room:
|
||||
// a dark song gives a near-black palette, and palette-dark walls are
|
||||
// unlightable — the room read as a black void with a floating screen.
|
||||
// Normalise the tint so the song colours the room without darkening it.
|
||||
const roomLight = vState ? vState.light : 0.7;
|
||||
const tintMax = Math.max(c0[0], c0[1], c0[2], 1e-3);
|
||||
const tint = [c0[0] / tintMax, c0[1] / tintMax, c0[2] / tintMax];
|
||||
// Bright songs light their own room through the palette; dark songs
|
||||
// cannot (their tint is near-black even normalised against the level
|
||||
// the room needs). So the room's level compensates the palette's:
|
||||
// dark palette → higher level, bright palette → lower. Keeps the room
|
||||
// present for drone without flooding it for centre.
|
||||
const palLum = 0.2126 * c0[0] + 0.7152 * c0[1] + 0.0722 * c0[2];
|
||||
const comp = 0.55 + Math.max(0, 0.45 - palLum) * 1.1;
|
||||
const wallLevel = (0.22 + roomLight * 0.30) * comp;
|
||||
const floorLevel = (0.18 + roomLight * 0.26) * comp;
|
||||
instance.floorMat.color.setRGB(tint[0] * floorLevel, tint[1] * floorLevel, tint[2] * floorLevel);
|
||||
instance.floorMat.roughness = 0.85;
|
||||
for (const w of instance.walls) {
|
||||
w.material.color.setRGB(tint[0] * wallLevel, tint[1] * wallLevel, tint[2] * wallLevel);
|
||||
}
|
||||
if (instance.sun) instance.sun.intensity = 0.5 + roomLight * 1.0;
|
||||
if (instance.ambient) instance.ambient.intensity = 0.32 + roomLight * 0.5;
|
||||
// Window brightness from story light. The IMAGE is the viz texture
|
||||
// (emissiveMap); emissive color stays near-white so the viz keeps its
|
||||
// own colors. Palette tint reaches the room via floor/walls instead.
|
||||
{
|
||||
const lightMul = vState ? vState.light : 1;
|
||||
instance.windowMat.emissive.setRGB(1, 1, 1);
|
||||
instance.windowMat.emissiveIntensity = (params.light || 0.7) * (0.75 + lightMul * 0.5);
|
||||
if (instance.reflectMat) {
|
||||
// Tint the spill toward the palette floor colour so it melts into
|
||||
// the floor instead of reading as a lit slab with its own edge.
|
||||
instance.reflectMat.emissive.setRGB(c0[0] * 0.5, c0[1] * 0.48, c0[2] * 0.55);
|
||||
instance.reflectMat.color.setRGB(c0[0] * 0.10, c0[1] * 0.10, c0[2] * 0.12);
|
||||
instance.reflectMat.emissiveIntensity = 0.5 * (params.light || 0.7) * (0.75 + lightMul * 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
// Fog via space + story: resolution (fog 0.55) closes the room in,
|
||||
// climax (0.18) opens it. Swing near AND far so the depth change reads.
|
||||
if (scene.fog) {
|
||||
const fogMul = vState ? vState.fog : 0.35;
|
||||
const fogDepth = (space ? 12 + space.depth * 6 : 15) * (0.7 + fogMul * 0.75);
|
||||
scene.fog.near = fogDepth;
|
||||
scene.fog.far = fogDepth + 10 + fogMul * 10;
|
||||
scene.fog.color.setRGB(c0[0] * 0.06, c0[1] * 0.06, c0[2] * 0.08);
|
||||
}
|
||||
|
||||
// Rebuild population if needed (deterministic, seeded). The arrangement
|
||||
// salt is part of the key: same salt → same layout, guaranteed
|
||||
// carryover; a new salt after a story jump re-hangs the room (always
|
||||
// under a camera cut — see ArcDriver.venueJumpAt). Nothing else here
|
||||
// may rebuild mid-song: no per-section seeds in this key.
|
||||
const arrangement = venueArrangement ?? instance.venueArrangement ?? 0;
|
||||
instance.venueArrangement = arrangement;
|
||||
const popKey = vs ? `${vs.seed}:${vs.population ? vs.population.count : 0}:${identity ? identity.cast.protagonist.sides : 0}:${actorSpec ? actorSpec.seed : 0}:a${arrangement}` : `no-vs:${instance.seed}:a${arrangement}`;
|
||||
const needRebuild = instance.builtKey !== popKey || !instance.popCount;
|
||||
if (needRebuild && identity && actorSpec && actorSpec.form) {
|
||||
for (const child of [...instance.popGroup.children]) {
|
||||
instance.popGroup.remove(child);
|
||||
child.traverse((obj) => {
|
||||
if (obj.isMesh) {
|
||||
if (obj.geometry) obj.geometry.dispose();
|
||||
if (obj.material) {
|
||||
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
||||
mats.forEach((m) => m.dispose());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
const count = vs && vs.population ? Math.max(2, Math.min(12, vs.population.count | 0)) : 5;
|
||||
const spread = vs && vs.population ? vs.population.spread || 0.7 : 0.7;
|
||||
const jitter = vs && vs.population ? vs.population.jitter || 0.25 : 0.25;
|
||||
// One actor, furnished as a roomful: the first build makes the
|
||||
// track's monolith body (the same geometry the viz hero uses), and
|
||||
// each memory unwraps one of its parts. Unwrapping per part rather
|
||||
// than cloning the whole body matters — cloning stamped the same
|
||||
// ~4-part composite at 0.2 scale, so a radial assembly read as grey
|
||||
// noise. Parts are the limbs; a room of limbs reads as furniture.
|
||||
let bodyParts = null;
|
||||
try {
|
||||
const body = actorToGeometry(actorSpec, identity, T);
|
||||
bodyParts = body.children.slice();
|
||||
} catch { bodyParts = null; }
|
||||
if (!bodyParts || !bodyParts.length) {
|
||||
const g = new T.BoxGeometry(0.3, 0.3, 0.3);
|
||||
const m = new T.Mesh(g, new T.MeshStandardMaterial({ color: 0xffffff }));
|
||||
m.userData.partIndex = 0;
|
||||
bodyParts = [m];
|
||||
}
|
||||
// Deterministic positions — golden-angle spiral + separation passes.
|
||||
// The old sin-scatter clustered near the centre, and at furniture
|
||||
// scale (1+ units wide) neighbours interpenetrated. The spiral gives
|
||||
// even coverage with an open middle for the camera; the relaxation
|
||||
// below enforces a minimum gap so no two memories touch.
|
||||
const seeds = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const frac = (i + 0.5) / Math.max(1, count);
|
||||
// Arrangement salt rotates the whole spiral, so a re-hang is a
|
||||
// visibly new layout built from the same objects — deterministic
|
||||
// per (seed, arrangement), stable across seeks.
|
||||
const ang = i * 2.39996 + (instance.seed % 628) * 0.01 + arrangement * 2.39996 * 3;
|
||||
const rad = (2.4 + 5.6 * Math.sqrt(frac)) * (0.7 + spread * 0.45);
|
||||
let rx = Math.cos(ang) * rad + Math.sin(i * 78.233) * jitter * 1.2;
|
||||
let rz = Math.sin(ang) * rad * 0.78 - 1.2 + Math.cos(i * 43.12) * jitter;
|
||||
rx = Math.max(-9, Math.min(9, rx));
|
||||
// Never in front of the room: the camera pushes as close as z≈1.3,
|
||||
// so anything at z>-0.5 is at the lens and reads as a bar across
|
||||
// the viz. Furniture lives in the back two-thirds, toward the window.
|
||||
rz = Math.max(-8.5, Math.min(-0.5, rz));
|
||||
seeds.push([rx, rz]);
|
||||
}
|
||||
// Repulsion passes: push overlapping pairs apart. Deterministic,
|
||||
// analytic, no wall-clock — same inputs always give same layout.
|
||||
const minDist = 2.9;
|
||||
for (let pass = 0; pass < 10; pass++) {
|
||||
for (let a = 0; a < count; a++) {
|
||||
for (let b = a + 1; b < count; b++) {
|
||||
const dx = seeds[b][0] - seeds[a][0];
|
||||
const dz = seeds[b][1] - seeds[a][1];
|
||||
const d = Math.hypot(dx, dz);
|
||||
if (d >= minDist || d < 1e-4) continue;
|
||||
const push = (minDist - d) * 0.5;
|
||||
const nx = dx / d, nz = dz / d;
|
||||
seeds[a][0] = Math.max(-9, Math.min(9, seeds[a][0] - nx * push));
|
||||
seeds[a][1] = Math.max(-8.5, Math.min(-0.5, seeds[a][1] - nz * push));
|
||||
seeds[b][0] = Math.max(-9, Math.min(9, seeds[b][0] + nx * push));
|
||||
seeds[b][1] = Math.max(-8.5, Math.min(-0.5, seeds[b][1] + nz * push));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < count; i++) {
|
||||
const rxClamped = seeds[i][0];
|
||||
const rzClamped = seeds[i][1];
|
||||
const ry = -1.15 + 0.15 + Math.sin(i * 5.5) * 0.25;
|
||||
|
||||
const src = bodyParts[i % bodyParts.length];
|
||||
const m = new T.Mesh(
|
||||
src.geometry,
|
||||
new T.MeshStandardMaterial({ color: 0xffffff }),
|
||||
);
|
||||
m.userData.partIndex = src.userData.partIndex ?? (i % bodyParts.length);
|
||||
const palIdx = vs && vs.paletteMap ? vs.paletteMap[i % vs.paletteMap.length] : i % pal.length;
|
||||
const cc = pal[palIdx % pal.length] || pal[0];
|
||||
// Faint emissive in the furniture's own colour: in the darkest
|
||||
// rooms (darkest songs pick the chamber by design) lit-only
|
||||
// limbs go black and the traverse moves the camera through
|
||||
// nothing visible. Kept low — these are memories, not lamps.
|
||||
m.material = new T.MeshStandardMaterial({
|
||||
color: new T.Color(cc[0], cc[1], cc[2]),
|
||||
emissive: new T.Color(cc[0] * 0.14, cc[1] * 0.14, cc[2] * 0.14),
|
||||
emissiveIntensity: 1.0,
|
||||
roughness: 0.45 + styleWeight * 0.12,
|
||||
metalness: 0.08,
|
||||
transparent: opacity < 0.999,
|
||||
opacity,
|
||||
});
|
||||
m.castShadow = false;
|
||||
m.receiveShadow = false;
|
||||
const g = new T.Group();
|
||||
g.add(m);
|
||||
// Small furniture, not a second hero: part geometry is already
|
||||
// ~0.5–1.0 units in radius, and the camera walks among it, so a
|
||||
// unit-scale limb at 3 units fills the frame like a monument.
|
||||
// Memories stay well under half scale; elementScale only nudges
|
||||
// within that — a huge-form track gets noticeably bigger
|
||||
// memories, not a room of heroes.
|
||||
const s = (0.22 + elementScale * 0.28) * (1 - (i / Math.max(1, count)) * 0.25);
|
||||
g.scale.setScalar(s * (0.9 + shapeSides * 0.015));
|
||||
g.position.set(rxClamped, ry, rzClamped);
|
||||
g.rotation.y = shapeTilt + i * 0.9 + Math.sin(i) * 0.5;
|
||||
g.rotation.x = Math.sin(i * 1.3) * 0.22;
|
||||
g.userData.baseY = ry;
|
||||
g.userData.index = i;
|
||||
instance.popGroup.add(g);
|
||||
}
|
||||
instance.builtKey = popKey;
|
||||
instance.popCount = count;
|
||||
}
|
||||
|
||||
// Analytic animation — bob + slow yaw, no integration
|
||||
for (const g of instance.popGroup.children) {
|
||||
const idx = g.userData.index || 0;
|
||||
const baseY = g.userData.baseY || 0;
|
||||
g.position.y = baseY + Math.sin(t * 0.35 + idx * 1.1) * 0.06;
|
||||
g.rotation.y += 0; // keep deterministic: set rather than accumulate
|
||||
const yaw = shapeTilt + idx * 0.9 + Math.sin(t * 0.18 + idx) * 0.28;
|
||||
g.rotation.y = yaw;
|
||||
// Per-section popMul would modulate visible/count — V1 keeps all visible
|
||||
{ const popMul = vState ? vState.popMul : 1; const showIndex = g.userData.index / Math.max(1, instance.popCount); g.visible = opacity > 0.01 && (showIndex < (0.35 + popMul * 0.75)); }
|
||||
for (const mesh of g.children) {
|
||||
if (mesh.isMesh) {
|
||||
mesh.material.opacity = opacity;
|
||||
mesh.material.transparent = opacity < 0.999;
|
||||
// The room's population breathes with the story arc: dim in
|
||||
// setup, bright at the climax. Pure f(venueState), no clock.
|
||||
if (vState && mesh.material.emissiveIntensity !== undefined) {
|
||||
mesh.material.emissiveIntensity = 0.5 + vState.light * 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Subtle room drift from camera trait
|
||||
const camTrait = personality ? personality.camera : null;
|
||||
const camSway = camTrait ? camTrait.sway : 0;
|
||||
const camSwayRate = camTrait ? camTrait.swayRate : 0.09;
|
||||
instance.room.position.x = Math.sin(t * camSwayRate) * camSway * 0.35;
|
||||
instance.room.position.z = Math.cos(t * camSwayRate * 0.83) * camSway * 0.22;
|
||||
|
||||
void perSectionMul; void spaceHorizon; void instance; void params; void T;
|
||||
},
|
||||
};
|
||||
|
||||
export default chamber;
|
||||
@@ -0,0 +1,78 @@
|
||||
// Projection helper — viz texture → venue surface (V1 placeholder, no RTT yet).
|
||||
//
|
||||
// In V1 compositing stays no-RTT: venue and viz are separate Model/Shader layers
|
||||
// blended in Compositor. This module is the V1.5 contract so the shape exists
|
||||
// before the cost does: stage.screen, chamber.window, future passage.skydome
|
||||
// all share the same `vizTexture → material.map` plumbing at half-res.
|
||||
//
|
||||
// V1 implementations are no-ops / DataTexture fallbacks so the module can be
|
||||
// imported without a WebGL context (tests, headless gallery). V1.5 will swap
|
||||
// the DataTexture for a real RenderTarget texture.
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* Create a venue projection texture at half-res (bloomA size).
|
||||
* Headless fallback uses DataTexture so `document` is not required.
|
||||
* @param {number} w width of venue target
|
||||
* @param {number} h height of venue target
|
||||
* @returns {THREE.Texture}
|
||||
*/
|
||||
export function createVenueTexture(w = 640, h = 360) {
|
||||
// Try CanvasTexture when DOM exists; otherwise DataTexture.
|
||||
try {
|
||||
if (typeof document !== 'undefined' && document.createElement) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = Math.max(1, Math.floor(w));
|
||||
canvas.height = Math.max(1, Math.floor(h));
|
||||
const g = canvas.getContext('2d');
|
||||
if (g) {
|
||||
g.fillStyle = '#101018';
|
||||
g.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
const tex = new THREE.CanvasTexture(canvas);
|
||||
tex.wrapS = THREE.ClampToEdgeWrapping;
|
||||
tex.wrapT = THREE.ClampToEdgeWrapping;
|
||||
tex.needsUpdate = true;
|
||||
tex.userData.isVenuePlaceholder = true;
|
||||
return tex;
|
||||
}
|
||||
} catch { /* fall through */ }
|
||||
const W = Math.max(1, Math.floor(w));
|
||||
const H = Math.max(1, Math.floor(h));
|
||||
const data = new Uint8Array(W * H * 4);
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
data[i] = 16; data[i + 1] = 16; data[i + 2] = 24; data[i + 3] = 255;
|
||||
}
|
||||
const tex = new THREE.DataTexture(data, W, H, THREE.RGBAFormat);
|
||||
tex.needsUpdate = true;
|
||||
tex.wrapS = THREE.ClampToEdgeWrapping;
|
||||
tex.wrapT = THREE.ClampToEdgeWrapping;
|
||||
tex.userData.isVenuePlaceholder = true;
|
||||
return tex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind a viz texture onto a venue mesh.
|
||||
* @param {THREE.Mesh} mesh venue hero surface (screen / window / skydome)
|
||||
* @param {THREE.Texture} vizTexture texture produced from viz layerTarget
|
||||
* @param {string} mapping 'screen' | 'window' | 'reflections' | 'skydome'
|
||||
*/
|
||||
export function bindVenueTexture(mesh, vizTexture, mapping = 'screen') {
|
||||
if (!mesh || !mesh.material) return;
|
||||
const mat = mesh.material;
|
||||
// V1 treats every mapping as emissive screen; V1.5 will specialise.
|
||||
if (mapping === 'screen' || mapping === 'window') {
|
||||
mat.map = vizTexture || mat.map;
|
||||
mat.emissiveMap = vizTexture || mat.emissiveMap;
|
||||
mat.needsUpdate = true;
|
||||
} else if (mapping === 'reflections') {
|
||||
mat.map = vizTexture || mat.map;
|
||||
mat.roughness = 0.35;
|
||||
mat.metalness = 0.22;
|
||||
mat.needsUpdate = true;
|
||||
} else {
|
||||
mat.map = vizTexture || mat.map;
|
||||
mat.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Venue registry — the venue IS the ground.
|
||||
// Mirrors src/scenes/registry.js pattern: modules carry traits/consumes/venue:true,
|
||||
// validated by params/schema.js and selected via census (signatureWeight) in
|
||||
// VenueGenerator. Venue is higher-level than viz — picked first, viz second.
|
||||
|
||||
import { validateModule } from '../params/schema.js';
|
||||
import { chamber } from './chamber.js';
|
||||
import { stage } from './stage.js';
|
||||
import { ride } from './ride.js';
|
||||
|
||||
export const ARCHETYPES = ['chamber', 'stage', 'ride'];
|
||||
|
||||
const MODULES = [chamber, stage, ride];
|
||||
|
||||
const errors = [];
|
||||
for (const m of MODULES) {
|
||||
const e = validateModule(m);
|
||||
if (e.length) errors.push(...e);
|
||||
if (!m.archetype || !ARCHETYPES.includes(m.archetype)) errors.push(`${m.name}: unknown archetype '${m.archetype}'`);
|
||||
if (!m.venue) errors.push(`${m.name}: venue registry entry must have venue:true`);
|
||||
}
|
||||
if (errors.length) {
|
||||
console.error('[venue registry] invalid venue modules:\n' + errors.join('\n'));
|
||||
}
|
||||
|
||||
export const venues = MODULES;
|
||||
export const venueErrors = errors;
|
||||
|
||||
export function venueByName(name) {
|
||||
return MODULES.find((m) => m.name === name) || null;
|
||||
}
|
||||
|
||||
export function venuesInArchetype(archetype) {
|
||||
return MODULES.filter((m) => m.archetype === archetype);
|
||||
}
|
||||
|
||||
export default venues;
|
||||
@@ -0,0 +1,436 @@
|
||||
// VENUE: Ride — a night drive. A randomly generated vehicle (the track's
|
||||
// monolith body as chassis, signature-form panels) carries actor passengers;
|
||||
// the viz plays on the car radio screen; the road scrolls beneath in chase
|
||||
// mode or the world sits still in stationary mode. Camera orbits, pushes in
|
||||
// and pulls out per section.
|
||||
//
|
||||
// One venue = one place (this car, this road). Primitives + actorToGeometry
|
||||
// only — no glTF kit. Per-section evolution via venueState
|
||||
// {light,fog,popMul,reachMul} from VenueSpec.perSection, interpolated by the
|
||||
// ArcDriver (never stepped).
|
||||
|
||||
import { actorToGeometry } from '../actors/meshes.js';
|
||||
|
||||
export const ride = {
|
||||
name: 'Ride',
|
||||
archetype: 'ride',
|
||||
family: 'structural',
|
||||
kind: 'model',
|
||||
venue: true,
|
||||
consumes: ['form', 'ink', 'staging'],
|
||||
traits: ['shape', 'space', 'camera', 'style'],
|
||||
texture: 0.4,
|
||||
// The radio is a small bright screen in a dark cabin — it earns the blaze
|
||||
// about as often as the stage wall.
|
||||
reach: 0.6,
|
||||
|
||||
params: {
|
||||
density: { type: 'float', range: [0.2, 0.9], default: 0.5, uniform: 'u_density', bias: 'density' },
|
||||
speed: { type: 'float', range: [0, 1.5], default: 0.7, uniform: 'u_speed', bias: 'motion' },
|
||||
light: { type: 'float', range: [0.4, 1.3], default: 0.85, uniform: 'u_light', bias: 'energy' },
|
||||
palette: { type: 'palette', count: 4 },
|
||||
},
|
||||
|
||||
reactive: {},
|
||||
|
||||
// Venue RTT: the compositor calls this with the finished viz texture after
|
||||
// compositing the viz stack into vizTarget. The radio screen samples it,
|
||||
// so the viz plays ON the dash rather than composited over the venue.
|
||||
bindVizTexture(instance, texture) {
|
||||
if (!instance || !texture) return;
|
||||
if (instance.radioMat && instance.radioMat.emissiveMap !== texture) {
|
||||
instance.radioMat.emissiveMap = texture;
|
||||
instance.radioMat.needsUpdate = true;
|
||||
}
|
||||
},
|
||||
|
||||
// Radio-glow wash for the viz composite — arcs with venueState.light.
|
||||
// See Layer.washColor.
|
||||
washColor(instance) {
|
||||
const st = (instance && instance.venueState) || null;
|
||||
const light = st ? st.light : 0.7;
|
||||
const glow = 0.08 + light * 0.1;
|
||||
return [glow, glow, glow * 1.15];
|
||||
},
|
||||
|
||||
build({ scene, seed, params, actorSpec, venueSpec, THREE }) {
|
||||
const T = THREE;
|
||||
const rng = mulberry(seed >>> 0 || 1);
|
||||
|
||||
// Road: long dark plane the car sits over. In chase mode its texture
|
||||
// offset scrolls (see update); in stationary it holds still.
|
||||
const roadMat = new T.MeshStandardMaterial({
|
||||
color: new T.Color(0.09, 0.09, 0.11),
|
||||
roughness: 0.9,
|
||||
metalness: 0.05,
|
||||
});
|
||||
const road = new T.Mesh(new T.PlaneGeometry(30, 120), roadMat);
|
||||
road.rotation.x = -Math.PI / 2;
|
||||
road.position.set(0, -1.15, -30);
|
||||
scene.add(road);
|
||||
|
||||
// Lane dashes: small emissive quads scrolling with the road, so speed
|
||||
// reads even when the asphalt is near-black.
|
||||
const dashMat = new T.MeshStandardMaterial({
|
||||
color: new T.Color(0.05, 0.05, 0.05),
|
||||
emissive: new T.Color(0.9, 0.85, 0.6),
|
||||
emissiveIntensity: 0.7,
|
||||
roughness: 0.6,
|
||||
});
|
||||
const dashGeo = new T.PlaneGeometry(0.18, 2.2);
|
||||
const dashes = new T.Group();
|
||||
dashes.name = 'dashes';
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const d = new T.Mesh(dashGeo, dashMat);
|
||||
d.rotation.x = -Math.PI / 2;
|
||||
d.position.set(i % 2 === 0 ? -1.6 : 1.6, -1.13, -70 + i * 6);
|
||||
dashes.add(d);
|
||||
}
|
||||
scene.add(dashes);
|
||||
|
||||
// Lane edge glow: two long emissive strips so the road reads as a
|
||||
// road even when the asphalt is near-black.
|
||||
const edgeMat = new T.MeshStandardMaterial({
|
||||
color: new T.Color(0.05, 0.05, 0.06),
|
||||
emissive: new T.Color(0.5, 0.55, 0.8),
|
||||
emissiveIntensity: 0.5,
|
||||
roughness: 0.7,
|
||||
});
|
||||
const edgeGeo = new T.PlaneGeometry(0.12, 120);
|
||||
const edges = [];
|
||||
for (const ex of [-3.2, 3.2]) {
|
||||
const edge = new T.Mesh(edgeGeo, edgeMat);
|
||||
edge.rotation.x = -Math.PI / 2;
|
||||
edge.position.set(ex, -1.12, -30);
|
||||
scene.add(edge);
|
||||
edges.push(edge);
|
||||
}
|
||||
|
||||
// The car: a group at the origin facing -z (driving away from camera
|
||||
// start). Body panels come from the track's actor; chassis/cabin/wheels
|
||||
// are seeded primitives so every song's vehicle differs.
|
||||
const car = new T.Group();
|
||||
car.name = 'car';
|
||||
scene.add(car);
|
||||
|
||||
// Cabin glow + headlights so the car reads at night.
|
||||
const ambient = new T.AmbientLight(0xffffff, 0.5);
|
||||
scene.add(ambient);
|
||||
const key = new T.DirectionalLight(0xcfe0ff, 1.0);
|
||||
key.position.set(3.5, 5.0, 6.0);
|
||||
key.castShadow = false;
|
||||
scene.add(key);
|
||||
const tail = new T.PointLight(0xff2a1a, 2.5, 10);
|
||||
tail.position.set(0, 0.2, 2.6);
|
||||
scene.add(tail);
|
||||
|
||||
scene.background = null;
|
||||
scene.fog = new T.Fog(0x07070c, 14, 46);
|
||||
scene.userData.transparentBackground = true;
|
||||
|
||||
// Passenger container — actor riders seated in the cabin.
|
||||
const crew = new T.Group();
|
||||
crew.name = 'crew';
|
||||
car.add(crew);
|
||||
|
||||
return {
|
||||
car, road, roadMat, dashes, dashMat, edges, edgeMat, crew,
|
||||
ambient, key, tail,
|
||||
radioMat: null, radioMesh: null,
|
||||
bodyMeshes: [],
|
||||
seed,
|
||||
venueSpec: venueSpec || null,
|
||||
builtKey: null,
|
||||
popCount: 0,
|
||||
// Chase (road scrolls, world rushes) vs stationary (parked, camera
|
||||
// does all the moving). Drawn once per video from the venue seed.
|
||||
chase: (rng() < 0.62),
|
||||
bodyHue: rng(),
|
||||
};
|
||||
},
|
||||
|
||||
update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, venueSpec, venueState, venueArrangement = null, THREE }) {
|
||||
const T = THREE;
|
||||
const t = timeline.time;
|
||||
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 identity = personality ? personality.identity : null;
|
||||
const shape = personality ? personality.shape : null;
|
||||
const space = personality ? personality.space : null;
|
||||
const style = personality ? personality.style : null;
|
||||
|
||||
const shapeTilt = shape ? shape.tilt : 0;
|
||||
const styleWeight = style ? style.lineWeight : 0.5;
|
||||
const spaceHorizon = space ? space.horizon : 0.5;
|
||||
void shapeTilt;
|
||||
|
||||
const vs = venueSpec || instance.venueSpec;
|
||||
const vState = venueState || instance.venueState || null;
|
||||
if (vState) instance.venueState = vState;
|
||||
const arrangement = venueArrangement ?? instance.venueArrangement ?? 0;
|
||||
instance.venueArrangement = arrangement;
|
||||
|
||||
const c0 = pal[0];
|
||||
const fieldLight = vState ? vState.light : 0.7;
|
||||
// Asphalt takes hue from the palette, level from the section — a night
|
||||
// road you can still see.
|
||||
const tMax = Math.max(c0[0], c0[1], c0[2], 1e-3);
|
||||
const palLum = 0.2126 * c0[0] + 0.7152 * c0[1] + 0.0722 * c0[2];
|
||||
const comp = 0.55 + Math.max(0, 0.45 - palLum) * 1.1;
|
||||
const roadLevel = (0.16 + fieldLight * 0.24) * comp;
|
||||
instance.roadMat.color.setRGB(
|
||||
(c0[0] / tMax) * roadLevel, (c0[1] / tMax) * roadLevel, (c0[2] / tMax) * roadLevel);
|
||||
if (instance.key) instance.key.intensity = 0.7 + fieldLight * 1.1;
|
||||
if (instance.ambient) instance.ambient.intensity = 0.4 + fieldLight * 0.5;
|
||||
if (instance.tail) instance.tail.intensity = 1.2 + fieldLight * 2;
|
||||
if (scene.fog) {
|
||||
const fogMul = vState ? vState.fog : 0.35;
|
||||
const fogDepth = (space ? 10 + space.depth * 8 : 15) * (0.9 + fogMul * 0.45);
|
||||
scene.fog.near = fogDepth;
|
||||
scene.fog.far = fogDepth + 30;
|
||||
scene.fog.color.setRGB(c0[0] * 0.05, c0[1] * 0.05, c0[2] * 0.07);
|
||||
}
|
||||
|
||||
// --- vehicle body (built once; arrangement re-hangs it) ---
|
||||
const popKey = vs
|
||||
? `${vs.seed}:ride:${identity ? identity.cast.protagonist.sides : 0}:${actorSpec ? actorSpec.seed : 0}:a${arrangement}`
|
||||
: `no-vs:${instance.seed}:a${arrangement}`;
|
||||
if ((instance.builtKey !== popKey || !instance.popCount) && identity && actorSpec && actorSpec.form) {
|
||||
// Clear previous build.
|
||||
for (const m of instance.bodyMeshes) {
|
||||
instance.car.remove(m);
|
||||
m.traverse((o) => {
|
||||
if (o.isMesh) {
|
||||
if (o.geometry) o.geometry.dispose();
|
||||
if (o.material) {
|
||||
const mats = Array.isArray(o.material) ? o.material : [o.material];
|
||||
mats.forEach((mm) => mm.dispose());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
instance.bodyMeshes = [];
|
||||
while (instance.crew.children.length) instance.crew.remove(instance.crew.children[0]);
|
||||
|
||||
const rng = mulberry(((vs ? vs.seed : instance.seed) ^ Math.imul(arrangement + 1, 0x9e3779b9)) >>> 0);
|
||||
const cc = (i) => pal[(vs && vs.paletteMap ? vs.paletteMap[i % vs.paletteMap.length] : i) % pal.length] || pal[0];
|
||||
|
||||
// Chassis: the track's monolith body, squashed wide, low and long.
|
||||
// Same character as the viz hero, so the car reads as this track's
|
||||
// vehicle rather than a stock model.
|
||||
try {
|
||||
const built = actorToGeometry(actorSpec, identity, T);
|
||||
const parts = built.children.slice();
|
||||
const body = new T.Group();
|
||||
body.name = 'chassis';
|
||||
for (let i = 0; i < Math.min(3, parts.length); i++) {
|
||||
const src = parts[(i + arrangement) % parts.length];
|
||||
const col = cc(i);
|
||||
const m = new T.Mesh(src.geometry, new T.MeshStandardMaterial({
|
||||
color: new T.Color(col[0] * 0.9, col[1] * 0.9, col[2] * 0.9),
|
||||
emissive: new T.Color(col[0] * 0.15, col[1] * 0.15, col[2] * 0.15),
|
||||
roughness: 0.3 + styleWeight * 0.15,
|
||||
metalness: 0.3,
|
||||
transparent: opacity < 0.999, opacity,
|
||||
}));
|
||||
m.userData.partIndex = src.userData.partIndex ?? i;
|
||||
m.scale.set(1.5, 0.55, 2.4);
|
||||
m.position.set(0, -0.15 + i * 0.12, -0.4 + i * 0.35);
|
||||
m.rotation.y = (rng() - 0.5) * 0.6;
|
||||
body.add(m);
|
||||
}
|
||||
instance.car.add(body);
|
||||
instance.bodyMeshes.push(body);
|
||||
} catch { /* body optional; cabin + wheels still read as a car */ }
|
||||
|
||||
// Cabin: low dark-glass tub so passenger heads clear the roofline
|
||||
// from rear angles — a tall box hid the whole crew.
|
||||
const glassCol = cc(3);
|
||||
const cabin = new T.Mesh(
|
||||
new T.BoxGeometry(1.7, 0.4, 2.1),
|
||||
new T.MeshStandardMaterial({
|
||||
color: new T.Color(0.04, 0.045, 0.06),
|
||||
emissive: new T.Color(glassCol[0] * 0.12, glassCol[1] * 0.12, glassCol[2] * 0.14),
|
||||
roughness: 0.15, metalness: 0.7,
|
||||
transparent: opacity < 0.999, opacity,
|
||||
}));
|
||||
cabin.position.set(0, 0.5, -0.2);
|
||||
instance.car.add(cabin);
|
||||
instance.bodyMeshes.push(cabin);
|
||||
|
||||
// Wheels: four dark torus + hub, spin with speed (see below).
|
||||
const wheelGeo = new T.TorusGeometry(0.34, 0.13, 8, 18);
|
||||
const hubGeo = new T.CylinderGeometry(0.14, 0.14, 0.1, 10);
|
||||
hubGeo.rotateZ(Math.PI / 2);
|
||||
instance.wheels = [];
|
||||
for (const [wx, wz] of [[-0.95, 1.05], [0.95, 1.05], [-0.95, -1.35], [0.95, -1.35]]) {
|
||||
const wcol = cc(1);
|
||||
const wheel = new T.Group();
|
||||
const tire = new T.Mesh(wheelGeo, new T.MeshStandardMaterial({
|
||||
color: new T.Color(0.03, 0.03, 0.035), roughness: 0.9,
|
||||
}));
|
||||
const hub = new T.Mesh(hubGeo, new T.MeshStandardMaterial({
|
||||
color: new T.Color(wcol[0], wcol[1], wcol[2]),
|
||||
emissive: new T.Color(wcol[0] * 0.25, wcol[1] * 0.25, wcol[2] * 0.25),
|
||||
roughness: 0.4, metalness: 0.6,
|
||||
}));
|
||||
wheel.add(tire); wheel.add(hub);
|
||||
wheel.position.set(wx, -0.72, wz);
|
||||
instance.car.add(wheel);
|
||||
instance.bodyMeshes.push(wheel);
|
||||
instance.wheels.push(wheel);
|
||||
}
|
||||
|
||||
// Radio: the viz screen. Rises above the dash on a small stalk so
|
||||
// it reads over the bodywork from chase angles — emissiveMap bound
|
||||
// per-frame by bindVizTexture, emissive stays near-white so the
|
||||
// viz keeps its own colours.
|
||||
const radioMat = new T.MeshStandardMaterial({
|
||||
color: new T.Color(0.02, 0.02, 0.02),
|
||||
emissive: new T.Color(1, 1, 1),
|
||||
emissiveIntensity: 1.2,
|
||||
roughness: 0.5, metalness: 0.2,
|
||||
side: T.DoubleSide,
|
||||
});
|
||||
const stalk = new T.Mesh(
|
||||
new T.CylinderGeometry(0.05, 0.07, 0.5, 8),
|
||||
new T.MeshStandardMaterial({ color: new T.Color(0.05, 0.05, 0.06), roughness: 0.6 }));
|
||||
stalk.position.set(0, 0.75, 0.9);
|
||||
instance.car.add(stalk);
|
||||
instance.bodyMeshes.push(stalk);
|
||||
const radio = new T.Mesh(new T.PlaneGeometry(1.3, 0.73), radioMat);
|
||||
radio.position.set(0, 1.15, 0.9);
|
||||
radio.rotation.x = -0.12;
|
||||
radio.rotation.y = Math.PI;
|
||||
radio.name = 'radio';
|
||||
instance.car.add(radio);
|
||||
instance.radioMat = radioMat;
|
||||
instance.radioMesh = radio;
|
||||
instance.bodyMeshes.push(radio);
|
||||
|
||||
// Headlight cones: two small translucent wedges so chase mode
|
||||
// reads. Kept short and faint — long cones filled the frame.
|
||||
const beamMat = new T.MeshBasicMaterial({
|
||||
color: new T.Color(0.75, 0.8, 0.9), transparent: true, opacity: 0.05,
|
||||
depthWrite: false, side: T.DoubleSide,
|
||||
});
|
||||
for (const bx of [-0.55, 0.55]) {
|
||||
const beamGeo = new T.ConeGeometry(0.45, 3.5, 12, 1, true);
|
||||
beamGeo.translate(0, -1.75, 0);
|
||||
beamGeo.rotateX(-Math.PI / 2 - 0.06);
|
||||
const beam = new T.Mesh(beamGeo, beamMat);
|
||||
beam.position.set(bx, -0.1, -2.2);
|
||||
instance.car.add(beam);
|
||||
instance.bodyMeshes.push(beam);
|
||||
}
|
||||
|
||||
// Passengers: actor riders — one part of the track's body each,
|
||||
// dealt round like chamber memories, seated in a row in the cabin.
|
||||
// Same limbs as the viz hero, so the crew reads as its passengers.
|
||||
let bodyParts = null;
|
||||
try {
|
||||
const body = actorToGeometry(actorSpec, identity, T);
|
||||
bodyParts = body.children.slice();
|
||||
} catch { bodyParts = null; }
|
||||
const count = vs && vs.population ? Math.max(2, Math.min(4, vs.population.count | 0)) : 2;
|
||||
for (let i = 0; i < count; i++) {
|
||||
let m;
|
||||
if (bodyParts && bodyParts.length) {
|
||||
const src = bodyParts[(i + arrangement) % bodyParts.length];
|
||||
const col = cc(i + 1);
|
||||
m = new T.Mesh(src.geometry, new T.MeshStandardMaterial({
|
||||
color: new T.Color(col[0], col[1], col[2]),
|
||||
emissive: new T.Color(col[0] * 0.3, col[1] * 0.3, col[2] * 0.3),
|
||||
emissiveIntensity: 1.0,
|
||||
roughness: 0.5, metalness: 0.1,
|
||||
transparent: opacity < 0.999, opacity,
|
||||
}));
|
||||
m.userData.partIndex = src.userData.partIndex ?? 0;
|
||||
} else {
|
||||
const col = cc(i + 1);
|
||||
m = new T.Mesh(
|
||||
new T.SphereGeometry(0.22, 12, 10),
|
||||
new T.MeshStandardMaterial({
|
||||
color: new T.Color(col[0], col[1], col[2]),
|
||||
emissive: new T.Color(col[0] * 0.3, col[1] * 0.3, col[2] * 0.3),
|
||||
roughness: 0.5,
|
||||
}));
|
||||
}
|
||||
const g = new T.Group();
|
||||
g.add(m);
|
||||
// Seat row: spread across the cabin width, heads above the glass.
|
||||
const fx = (i - (count - 1) / 2) * 0.62 + (rng() - 0.5) * 0.1;
|
||||
g.scale.setScalar(0.5);
|
||||
g.position.set(fx, 0.78, -0.25 + (rng() - 0.5) * 0.3);
|
||||
g.rotation.y = Math.PI + (rng() - 0.5) * 0.5;
|
||||
g.userData.baseY = g.position.y;
|
||||
g.userData.index = i;
|
||||
g.userData.phase = rng() * Math.PI * 2;
|
||||
instance.crew.add(g);
|
||||
}
|
||||
|
||||
instance.builtKey = popKey;
|
||||
instance.popCount = count;
|
||||
}
|
||||
|
||||
// --- motion: analytic, no integration ---
|
||||
const speed = (params.speed || 0.7) * (instance.chase ? 1 : 0);
|
||||
const rate = 1 + (features.bandLow || 0) * 0.6;
|
||||
// Road scroll in chase mode: dashes stream past; parked otherwise.
|
||||
if (instance.chase && speed > 0.01) {
|
||||
const dz = speed * 9 * rate;
|
||||
for (const d of instance.dashes.children) {
|
||||
let z = d.position.z + dz * 0.016;
|
||||
if (z > 4) z -= 72;
|
||||
d.position.z = z;
|
||||
}
|
||||
}
|
||||
// Wheels spin with speed; bob with the low band.
|
||||
const bob = Math.sin(t * 2.1) * 0.02 * (0.4 + (features.bandLow || 0));
|
||||
instance.car.position.y = bob * (instance.chase ? 1 : 0.3);
|
||||
instance.car.rotation.z = Math.sin(t * 0.9) * 0.008 * (instance.chase ? 1 : 0.3);
|
||||
if (instance.wheels) {
|
||||
for (const w of instance.wheels) w.rotation.x = t * speed * 6 * rate;
|
||||
}
|
||||
// Passengers bob gently, out of phase — analytic f(t), seek-stable.
|
||||
// popMul gates how many are visible (setup: driver + 1; climax: full car).
|
||||
const popMul = vState ? vState.popMul : 1;
|
||||
for (const g of instance.crew.children) {
|
||||
const idx = g.userData.index || 0;
|
||||
g.position.y = (g.userData.baseY || 0) + Math.sin(t * 0.5 + (g.userData.phase || 0)) * 0.03;
|
||||
g.rotation.y = Math.PI + Math.sin(t * 0.18 + idx * 1.3) * 0.22;
|
||||
const showIndex = idx / Math.max(1, instance.popCount);
|
||||
g.visible = opacity > 0.01 && (showIndex < (0.35 + popMul * 0.75));
|
||||
for (const mesh of g.children) {
|
||||
if (mesh.isMesh) {
|
||||
mesh.material.opacity = opacity;
|
||||
mesh.material.transparent = opacity < 0.999;
|
||||
if (vState && mesh.material.emissiveIntensity !== undefined) {
|
||||
mesh.material.emissiveIntensity = 0.5 + vState.light * 1.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Radio glow follows the section light; the IMAGE is the viz texture.
|
||||
if (instance.radioMat) {
|
||||
const l = vState ? vState.light : 1;
|
||||
instance.radioMat.emissive.setRGB(1, 1, 1);
|
||||
instance.radioMat.emissiveIntensity = (params.light || 0.85) * (0.7 + l * 0.7);
|
||||
}
|
||||
// Ground follows horizon like the stage field.
|
||||
instance.road.position.y = -1.15 + (spaceHorizon - 0.5) * 0.6;
|
||||
|
||||
void camera; void framing; void params;
|
||||
},
|
||||
};
|
||||
|
||||
function mulberry(seed) {
|
||||
let a = seed >>> 0;
|
||||
return function () {
|
||||
a |= 0; a = (a + 0x6D2B79F5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
export default ride;
|
||||
@@ -0,0 +1,314 @@
|
||||
// VENUE: Stage — hall / pylon field. Open ground plane at sigHorizonY(),
|
||||
// LED wall / hero screen for future RTT, Instanced-like pylons / legs
|
||||
// from actorToGeometry primitives via stageNode. One venue = one place.
|
||||
|
||||
import { actorToGeometry, castShape } from '../actors/meshes.js';
|
||||
|
||||
export const stage = {
|
||||
name: 'Stage',
|
||||
archetype: 'stage',
|
||||
family: 'structural',
|
||||
kind: 'model',
|
||||
venue: true,
|
||||
consumes: ['form', 'ink', 'staging'],
|
||||
traits: ['shape', 'space', 'camera', 'style'],
|
||||
texture: 0.4,
|
||||
// The stage wall is a 14-unit emissive screen — already the brightest thing
|
||||
// in the frame — so it blazes rarely, or the wall goes to paper.
|
||||
reach: 0.55,
|
||||
|
||||
params: {
|
||||
density: { type: 'float', range: [0.3, 1.0], default: 0.55, uniform: 'u_density', bias: 'density' },
|
||||
spread: { type: 'float', range: [0.6, 1.8], default: 1.1, uniform: 'u_spread' },
|
||||
light: { type: 'float', range: [0.4, 1.3], default: 0.85, uniform: 'u_light', bias: 'energy' },
|
||||
columns: { type: 'float', range: [2, 6], default: 4, uniform: 'u_columns', bias: 'density', slowAxis: true },
|
||||
palette: { type: 'palette', count: 4 },
|
||||
},
|
||||
|
||||
reactive: {},
|
||||
|
||||
// Venue RTT: the compositor calls this with the finished viz texture after
|
||||
// compositing the viz stack into vizTarget. The LED wall samples it, so the
|
||||
// viz appears ON the wall rather than composited over the venue.
|
||||
bindVizTexture(instance, texture) {
|
||||
if (!instance || !texture) return;
|
||||
if (instance.screenMat && instance.screenMat.emissiveMap !== texture) {
|
||||
instance.screenMat.emissiveMap = texture;
|
||||
instance.screenMat.needsUpdate = true;
|
||||
}
|
||||
},
|
||||
|
||||
// Glowing screen base like the chamber's — arcs with venueState.light.
|
||||
// See Layer.washColor.
|
||||
washColor(instance) {
|
||||
const st = (instance && instance.venueState) || null;
|
||||
const light = st ? st.light : 0.7;
|
||||
let r = 0.05, g = 0.05, b = 0.07;
|
||||
if (instance && instance.groundMat) {
|
||||
const c = instance.groundMat.color;
|
||||
r = c.r; g = c.g; b = c.b;
|
||||
}
|
||||
const glow = (0.07 + light * 0.09);
|
||||
return [r * 0.5 + glow, g * 0.5 + glow, b * 0.5 + glow * 1.1];
|
||||
},
|
||||
|
||||
build({ scene, seed, params, actorSpec, venueSpec, THREE }) {
|
||||
const T = THREE;
|
||||
const groundGeo = new T.PlaneGeometry(44, 44);
|
||||
const groundMat = new T.MeshStandardMaterial({
|
||||
color: new T.Color(0.13, 0.13, 0.15),
|
||||
roughness: 0.82,
|
||||
metalness: 0.05,
|
||||
});
|
||||
const ground = new T.Mesh(groundGeo, groundMat);
|
||||
ground.rotation.x = -Math.PI / 2;
|
||||
ground.position.y = -1.15;
|
||||
ground.receiveShadow = false;
|
||||
scene.add(ground);
|
||||
|
||||
// LED wall — the viz render surface. 14×7.875 is 16:9 like the viz
|
||||
// that feeds it, so the image arrives undistorted; emissiveMap carries
|
||||
// the viz (bound per-frame by bindVizTexture), emissive color stays
|
||||
// near-white so the viz keeps its own colors.
|
||||
const screenGeo = new T.PlaneGeometry(14, 7.875);
|
||||
const screenMat = new T.MeshStandardMaterial({
|
||||
color: new T.Color(0.02, 0.02, 0.02),
|
||||
emissive: new T.Color(1, 1, 1),
|
||||
emissiveIntensity: 1.0,
|
||||
roughness: 0.7,
|
||||
metalness: 0.1,
|
||||
});
|
||||
const screen = new T.Mesh(screenGeo, screenMat);
|
||||
screen.position.set(0, 2.2, -14.5);
|
||||
screen.name = 'screen';
|
||||
scene.add(screen);
|
||||
|
||||
const ambient = new T.AmbientLight(0xffffff, 0.55);
|
||||
scene.add(ambient);
|
||||
const key = new T.DirectionalLight(0xffffff, 1.15);
|
||||
key.position.set(2.4, 4.8, 3.0);
|
||||
key.castShadow = false;
|
||||
scene.add(key);
|
||||
const fill = new T.DirectionalLight(0xffffff, 0.32);
|
||||
fill.position.set(-2.8, 1.6, -2.2);
|
||||
scene.add(fill);
|
||||
|
||||
scene.background = null;
|
||||
scene.fog = new T.Fog(0x0a0a0f, 10, 26);
|
||||
scene.userData.transparentBackground = true;
|
||||
|
||||
const pylonContainer = new T.Group();
|
||||
pylonContainer.name = 'pylons';
|
||||
scene.add(pylonContainer);
|
||||
|
||||
return {
|
||||
ground, groundMat, screen, screenMat, ambient, key, fill,
|
||||
pylonContainer,
|
||||
seed,
|
||||
venueSpec: venueSpec || null,
|
||||
builtCols: -1,
|
||||
builtKey: null,
|
||||
pylonCount: 0,
|
||||
};
|
||||
},
|
||||
|
||||
update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, venueSpec, venueState, venueArrangement = null, THREE }) {
|
||||
const T = THREE;
|
||||
const t = timeline.time;
|
||||
const beat = features.beat || 0;
|
||||
void beat;
|
||||
const bandLow = features.bandLow || 0;
|
||||
const identity = personality ? personality.identity : null;
|
||||
const shape = personality ? personality.shape : null;
|
||||
const space = personality ? personality.space : null;
|
||||
const camTrait = personality ? personality.camera : null;
|
||||
const style = personality ? personality.style : null;
|
||||
|
||||
const shapeTilt = shape ? shape.tilt : 0;
|
||||
const shapeSides = shape ? shape.sides : 0;
|
||||
const spaceHorizon = space ? space.horizon : 0.5;
|
||||
const camSway = camTrait ? camTrait.sway : 0;
|
||||
const camSwayRate = camTrait ? camTrait.swayRate : 0.09;
|
||||
const camSpin = camTrait ? camTrait.spin : 0;
|
||||
const styleWeight = style ? style.lineWeight : 0.5;
|
||||
void shapeSides;
|
||||
|
||||
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 vs = venueSpec || instance.venueSpec;
|
||||
const vState = venueState || instance.venueState || null;
|
||||
const ink = identity ? identity.ink : null;
|
||||
const lattice = identity ? identity.lattice : null;
|
||||
void lattice;
|
||||
const elementScale = identity ? identity.lattice.elementScale : 0.35;
|
||||
void elementScale;
|
||||
const stageScaleVal = elementScale / 0.35;
|
||||
const latticeKind = lattice ? lattice.kind : 'grid';
|
||||
void stageScaleVal; void latticeKind;
|
||||
|
||||
// Materials from palette. The wall IMAGE is the viz texture
|
||||
// (emissiveMap); emissive color stays near-white so the viz keeps its
|
||||
// own colors, and story light moves intensity only.
|
||||
const c0 = pal[0];
|
||||
// The field breathes with the story arc like the chamber room does —
|
||||
// a static ground dilutes section changes into churn. Hue from the
|
||||
// palette, level from the field (same unlightable-dark fix as chamber).
|
||||
const fieldLight = vState ? vState.light : 0.7;
|
||||
const tMax = Math.max(c0[0], c0[1], c0[2], 1e-3);
|
||||
const palLum = 0.2126 * c0[0] + 0.7152 * c0[1] + 0.0722 * c0[2];
|
||||
const comp = 0.55 + Math.max(0, 0.45 - palLum) * 1.1;
|
||||
const fieldLevel = (0.18 + fieldLight * 0.26) * comp;
|
||||
instance.groundMat.color.setRGB(
|
||||
(c0[0] / tMax) * fieldLevel, (c0[1] / tMax) * fieldLevel, (c0[2] / tMax) * fieldLevel);
|
||||
if (instance.key) instance.key.intensity = 0.5 + fieldLight * 1.0;
|
||||
if (instance.ambient) instance.ambient.intensity = 0.3 + fieldLight * 0.45;
|
||||
instance.screenMat.emissive.setRGB(1, 1, 1);
|
||||
{ const l = vState ? vState.light : 1; instance.screenMat.emissiveIntensity = (params.light || 0.85) * (0.65 + l * 0.6); }
|
||||
// Ground level follows horizon
|
||||
const groundY = -1.15 + (spaceHorizon - 0.5) * 0.6;
|
||||
instance.ground.position.y = groundY;
|
||||
if (scene.fog) {
|
||||
const fogMul = vState ? vState.fog : 0.35; const fogDepth = (space ? 10 + space.depth * 8 : 15) * (0.9 + fogMul * 0.45);
|
||||
scene.fog.near = fogDepth;
|
||||
scene.fog.far = fogDepth + 18;
|
||||
scene.fog.color.setRGB(c0[0]*0.07, c0[1]*0.07, c0[2]*0.09);
|
||||
}
|
||||
|
||||
// Column count is locked at first build: it used to re-derive from live
|
||||
// params.columns (which drifts) and bandLow (which pumps), so the whole
|
||||
// pylon field rebuilt itself mid-song whenever drift crossed an integer
|
||||
// — the object-jump this change kills. A story jump may re-hang the
|
||||
// field via the arrangement salt; nothing else rebuilds it.
|
||||
const arrangement = venueArrangement ?? instance.venueArrangement ?? 0;
|
||||
instance.venueArrangement = arrangement;
|
||||
let cols = instance.builtCols;
|
||||
if (!(cols >= 2)) {
|
||||
cols = Math.max(2, Math.min(6, Math.round(((params.columns || 7) - 3) * 0.4 + 3 + bandLow * 0.1)));
|
||||
}
|
||||
const rows = Math.max(2, Math.min(3, 3));
|
||||
const identityKey = identity ? `${identity.cast.protagonist.sides}:${identity.ink.fill}:${latticeKind}:${actorSpec?actorSpec.seed:0}:${vs?vs.seed:0}:a${arrangement}` : `no-id:${actorSpec?actorSpec.seed:0}:a${arrangement}`;
|
||||
const needRebuild = instance.builtKey !== identityKey || !instance.pylonCount;
|
||||
|
||||
if (needRebuild && identity && actorSpec && actorSpec.form) {
|
||||
for (const child of [...instance.pylonContainer.children]) {
|
||||
instance.pylonContainer.remove(child);
|
||||
child.traverse((obj) => {
|
||||
if (obj.isMesh) {
|
||||
if (obj.geometry) obj.geometry.dispose();
|
||||
if (obj.material) {
|
||||
const mats = Array.isArray(obj.material) ? obj.material : [obj.material];
|
||||
mats.forEach((m) => m.dispose());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
let legShapeGeo = null;
|
||||
try {
|
||||
const s = castShape(identity.cast.chorus, 20);
|
||||
legShapeGeo = new T.ExtrudeGeometry(s, { depth: 0.42, bevelEnabled: false });
|
||||
legShapeGeo.translate(0,0,-0.21);
|
||||
} catch { legShapeGeo = new T.BoxGeometry(0.45,0.45,0.42); }
|
||||
|
||||
const baseRough = 0.42 + styleWeight*0.12 + (ink?ink.weight*0.08:0);
|
||||
// Crowns are ~1 unit wide at their part-scale, so columns get ~2.5+
|
||||
// units of lateral room and rows ~7 of depth, or neighbours
|
||||
// interpenetrate. cols/rows are already capped above to what fits:
|
||||
// fewer, wider-spaced pylons read as architecture, not clutter.
|
||||
for (let r=0; r<rows; r++) {
|
||||
const f = rows>1 ? r/(rows-1) : 0;
|
||||
const scale = 1*(1-f) + 0.18*f;
|
||||
const y0 = groundY + f*0.2;
|
||||
const zBase = -f*7.5 - 0.5;
|
||||
for (let c=0; c<cols; c++) {
|
||||
const pylon = new T.Group();
|
||||
// Fixed spacing per column, not a fixed total width: adding
|
||||
// columns widens the field instead of squeezing neighbours
|
||||
// together. ~2.9 units apart at spread 1.1 for ~1-unit crowns.
|
||||
pylon.position.set((c - (cols - 1) / 2) * (params.spread || 1.1) * 2.6, 0, zBase);
|
||||
pylon.userData.f = f;
|
||||
// Crown — one part of the track's body per pylon, dealt round
|
||||
// the field like cards (pylon i takes part i % parts). Every
|
||||
// crown was the whole assembly at 0.25 scale, so the field
|
||||
// read as N identical grey thumbnails; dealing parts makes
|
||||
// neighbours differ the way limbs differ.
|
||||
const crownGroup = new T.Group();
|
||||
crownGroup.name='crown';
|
||||
try {
|
||||
const built = actorToGeometry(actorSpec, identity, T);
|
||||
const parts = built.children.slice();
|
||||
const src = parts.length
|
||||
? parts[(c + r * cols) % parts.length]
|
||||
: null;
|
||||
if (src) {
|
||||
const m = new T.Mesh(
|
||||
src.geometry,
|
||||
new T.MeshStandardMaterial({ color: new T.Color(1, 1, 1) }),
|
||||
);
|
||||
m.userData.partIndex = src.userData.partIndex ?? 0;
|
||||
const cc = pal[(m.userData.partIndex||0) % pal.length] || pal[0];
|
||||
m.material = new T.MeshStandardMaterial({ color: new T.Color(cc[0],cc[1],cc[2]), roughness: baseRough, metalness: 0.07, transparent: opacity<0.999, opacity });
|
||||
crownGroup.add(m);
|
||||
} else {
|
||||
throw new Error('empty body');
|
||||
}
|
||||
} catch {
|
||||
const g=new T.BoxGeometry(0.2,0.2,0.2);
|
||||
const m=new T.Mesh(g,new T.MeshStandardMaterial({color: new T.Color(c0[0],c0[1],c0[2])}));
|
||||
crownGroup.add(m);
|
||||
}
|
||||
const h = 0.75*scale*1.1;
|
||||
crownGroup.position.set(0, y0 + h + 0.06*scale, 0);
|
||||
// Capped: elementScale runs to ~2.5× typical on huge-form
|
||||
// tracks, and an uncapped crown outgrows its column spacing.
|
||||
crownGroup.scale.setScalar((0.38+0.42*scale)*Math.min(stageScaleVal, 1.5)*0.9);
|
||||
crownGroup.userData.yawSeed = c*0.9 + r*0.4 + shapeTilt*0.4;
|
||||
pylon.add(crownGroup);
|
||||
// Leg rung
|
||||
const rung = new T.Mesh(legShapeGeo.clone(), new T.MeshStandardMaterial({ color: new T.Color(1,1,1), roughness: 0.55 }));
|
||||
rung.scale.set((h/2)*0.9, (h/2)*0.9, 0.85);
|
||||
rung.position.set(0, y0 + h*0.45, 0);
|
||||
const lc = pal[(c+r)%pal.length];
|
||||
rung.material.color.setRGB(lc[0],lc[1],lc[2]);
|
||||
rung.material.transparent = opacity<0.999;
|
||||
rung.material.opacity = opacity*0.95;
|
||||
rung.userData.yawSeed = r*0.6 + c*0.3;
|
||||
pylon.add(rung);
|
||||
pylon.userData.crown = crownGroup;
|
||||
pylon.userData.rung = rung;
|
||||
pylon.userData.y0 = y0;
|
||||
instance.pylonContainer.add(pylon);
|
||||
}
|
||||
}
|
||||
if (legShapeGeo) legShapeGeo.dispose();
|
||||
instance.builtCols = cols;
|
||||
instance.builtKey = identityKey;
|
||||
instance.pylonCount = cols*rows;
|
||||
}
|
||||
|
||||
// Analytic pose
|
||||
for (const pylon of instance.pylonContainer.children) {
|
||||
const crown = pylon.userData.crown;
|
||||
const rung = pylon.userData.rung;
|
||||
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; }
|
||||
}
|
||||
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;
|
||||
}
|
||||
{ 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;
|
||||
instance.pylonContainer.position.z = Math.cos(t*camSwayRate*0.7)*camSway*0.3;
|
||||
instance.pylonContainer.rotation.y = camSpin*t*0.03;
|
||||
void t; void params;
|
||||
},
|
||||
};
|
||||
|
||||
export default stage;
|
||||
@@ -0,0 +1,197 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { venues } from '../src/venues/registry.js';
|
||||
import { generateVenue } from '../src/venues/VenueGenerator.js';
|
||||
import { generateActorSet } from '../src/actors/ActorGenerator.js';
|
||||
import { generatePersonality } from '../src/look/Personality.js';
|
||||
import { generateIdentity } from '../src/look/Identity.js';
|
||||
import { Rng } from '../src/engine/rng.js';
|
||||
import { Compositor } from '../src/engine/Compositor.js';
|
||||
|
||||
const SUMMARY = {
|
||||
meanCentroid: 0.5, meanFlatness: 0.15, bpm: 120,
|
||||
dynamicRange: 0.6, meanLoudness: 0.4,
|
||||
};
|
||||
|
||||
function personalityAt(seed) {
|
||||
const rng = new Rng(seed);
|
||||
return generatePersonality(SUMMARY, rng.fork('personality'), () => 10, 4);
|
||||
}
|
||||
|
||||
test('every venue declares a reach rating in (0, 2]', () => {
|
||||
assert.ok(venues.length > 0, 'no venues registered');
|
||||
for (const v of venues) {
|
||||
assert.equal(typeof v.reach, 'number', `${v.name}: missing reach rating`);
|
||||
assert.ok(v.reach > 0 && v.reach <= 2, `${v.name}: reach ${v.reach} outside (0, 2]`);
|
||||
}
|
||||
});
|
||||
|
||||
test('venue spec carries a stable seed and per-section evolution', () => {
|
||||
const rng = new Rng(1234);
|
||||
const story = {
|
||||
sections: [
|
||||
{ act: 'setup' }, { act: 'development' },
|
||||
{ act: 'climax' }, { act: 'resolution' },
|
||||
],
|
||||
};
|
||||
const venue = generateVenue({
|
||||
summary: SUMMARY, rng: rng.fork('venue'),
|
||||
personality: personalityAt(99), director: null, story,
|
||||
sections: [{}, {}, {}, {}],
|
||||
});
|
||||
assert.ok(venue.seed !== undefined && Number.isInteger(venue.seed));
|
||||
assert.equal(venue.perSection.length, 4);
|
||||
// Climax is the brightest, most populated point of the arc.
|
||||
assert.ok(venue.perSection[2].light >= venue.perSection[0].light,
|
||||
'climax should be at least as bright as setup');
|
||||
assert.ok(venue.perSection[2].popMul >= venue.perSection[3].popMul,
|
||||
'climax should be at least as populated as resolution');
|
||||
// Deterministic: same seed, same spec.
|
||||
const again = generateVenue({
|
||||
summary: SUMMARY, rng: new Rng(1234).fork('venue'),
|
||||
personality: personalityAt(99), director: null, story,
|
||||
sections: [{}, {}, {}, {}],
|
||||
});
|
||||
assert.equal(again.seed, venue.seed);
|
||||
assert.deepEqual(again.perSection, venue.perSection);
|
||||
assert.deepEqual(again.rearrangeAt, venue.rearrangeAt);
|
||||
});
|
||||
|
||||
test('rearrangeAt only jumps on act changes, and the recap returns home', () => {
|
||||
const story = {
|
||||
recap: true,
|
||||
sections: [
|
||||
{ act: 'setup' }, { act: 'development' }, { act: 'development' },
|
||||
{ act: 'climax' }, { act: 'resolution' },
|
||||
],
|
||||
};
|
||||
const mk = (seed) => generateVenue({
|
||||
summary: SUMMARY, rng: new Rng(seed).fork('venue'),
|
||||
personality: personalityAt(99), director: null, story,
|
||||
sections: [{ kind: 'intro' }, { kind: 'build' }, { kind: 'drop' }, { kind: 'drop' }, { kind: 'outro' }],
|
||||
});
|
||||
const venue = mk(777);
|
||||
assert.equal(venue.rearrangeAt.length, 5);
|
||||
// Same-act section never rearranges.
|
||||
assert.equal(venue.rearrangeAt[2], venue.rearrangeAt[1],
|
||||
'same-act section must keep its arrangement');
|
||||
// Recap outro returns to the intro's room.
|
||||
assert.equal(venue.rearrangeAt[4], venue.rearrangeAt[0],
|
||||
'recap outro must return to arrangement 0');
|
||||
// Arrangement ids never decrease except at the recap.
|
||||
for (let i = 1; i < 4; i++) {
|
||||
assert.ok(venue.rearrangeAt[i] >= venue.rearrangeAt[i - 1],
|
||||
`arrangement went backwards at section ${i}`);
|
||||
}
|
||||
// Stable across regenerations.
|
||||
assert.deepEqual(mk(777).rearrangeAt, venue.rearrangeAt);
|
||||
});
|
||||
|
||||
test('venue room camera walks on a bounded, deterministic path', () => {
|
||||
// No WebGL needed: drive the camera method on a bare object.
|
||||
const comp = Object.create(Compositor.prototype);
|
||||
comp.sharedCamera = {
|
||||
position: { set(x, y, z) { this.x = x; this.y = y; this.z = z; } },
|
||||
rotation: {},
|
||||
lookAt() {},
|
||||
updateMatrixWorld() {},
|
||||
};
|
||||
const personality = personalityAt(7);
|
||||
const venue = {
|
||||
archetype: 'chamber', seed: 4242,
|
||||
perSection: [{ light: 0.7, fog: 0.35, popMul: 0.65, reachMul: 0.85 }],
|
||||
};
|
||||
const at = (time, journey) => {
|
||||
comp._updateVenueCamera(comp.sharedCamera, {
|
||||
framing: { scale: 1, shift: [0, 0] },
|
||||
personality, time, venue, journey,
|
||||
// Production always passes progress (Show.js); the journey-only
|
||||
// fallback exists for hand-built paths. Measure the real path.
|
||||
progress: time / 400,
|
||||
});
|
||||
return { ...comp.sharedCamera.position };
|
||||
};
|
||||
const a = at(10, 0.2), b = at(40, 0.6), c = at(10, 0.2);
|
||||
// Deterministic in (time, journey): same inputs, same position.
|
||||
assert.deepEqual(a, c);
|
||||
// It moves over 30 s — this is the device under test.
|
||||
const travelled = Math.hypot(b.x - a.x, b.y - a.y, b.z - a.z);
|
||||
assert.ok(travelled > 0.15, `camera barely moves: ${travelled.toFixed(3)} units in 30 s`);
|
||||
// Bounded: a walk that leaves the room fails the device.
|
||||
for (const t of [0, 30, 90, 200, 400]) {
|
||||
const p = at(t, 0.5);
|
||||
assert.ok(Math.abs(p.x) < 2.6, `x=${p.x.toFixed(2)} outside the room at t=${t}`);
|
||||
assert.ok(p.z > 0.4 && p.z < 8, `z=${p.z.toFixed(2)} outside the room at t=${t}`);
|
||||
}
|
||||
// Stage walks further than chamber (bigger room). The traverse is
|
||||
// journey-driven, so both probes move in journey as well as time.
|
||||
const stageAt = (time, journey) => {
|
||||
comp._updateVenueCamera(comp.sharedCamera, {
|
||||
framing: { scale: 1, shift: [0, 0] },
|
||||
personality, time, venue: { ...venue, archetype: 'stage' }, journey,
|
||||
progress: time / 400,
|
||||
});
|
||||
return { ...comp.sharedCamera.position };
|
||||
};
|
||||
const s0 = stageAt(0, 0.2), s1 = stageAt(37.5, 0.6);
|
||||
const chamberSpan = Math.hypot(b.x - a.x, b.y - a.y);
|
||||
const stageSpan = Math.hypot(s1.x - s0.x, s1.y - s0.y);
|
||||
assert.ok(stageSpan >= chamberSpan * 0.9,
|
||||
`stage walk (${stageSpan.toFixed(2)}) should reach at least as far as chamber (${chamberSpan.toFixed(2)})`);
|
||||
});
|
||||
|
||||
test('each venue module builds and updates headless with a furnished actor', () => {
|
||||
// THREE is ESM-only but importable from node; geometry build paths need no GL.
|
||||
const THREE = awaitImportThree();
|
||||
return THREE.then((T) => {
|
||||
const personality = personalityAt(21);
|
||||
const identity = generateIdentity(SUMMARY, new Rng(21).fork('identity'), 4);
|
||||
personality.identity = identity;
|
||||
const actors = generateActorSet(SUMMARY, new Rng(21).fork('actors'), personality, identity);
|
||||
for (const v of venues) {
|
||||
const scene = new T.Scene();
|
||||
const instance = v.build({
|
||||
scene, seed: 99, params: {}, actorSpec: actors.monolith,
|
||||
venueSpec: {
|
||||
seed: 5, population: { count: 4, spread: 0.7, jitter: 0.25 },
|
||||
paletteMap: [0, 1, 2, 3],
|
||||
perSection: [{ light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 }],
|
||||
},
|
||||
THREE: T,
|
||||
});
|
||||
assert.ok(instance, `${v.name}: build returned nothing`);
|
||||
v.update({
|
||||
instance, scene, camera: new T.PerspectiveCamera(),
|
||||
timeline: { time: 12.5, frame: 750, progress: 0.1 },
|
||||
features: {}, params: { light: 0.7, columns: 7, spread: 1.1 },
|
||||
palette: [[1, 0.9, 0.8], [0.5, 0.5, 0.7], [0.3, 0.4, 0.9], [0.9, 0.4, 0.3]],
|
||||
personality, framing: { scale: 1, shift: [0, 0] }, opacity: 1,
|
||||
actorSpec: actors.monolith,
|
||||
venueSpec: instance.venueSpec,
|
||||
venueState: { light: 0.7, fog: 0.35, popMul: 0.8, reachMul: 0.9 },
|
||||
THREE: T,
|
||||
});
|
||||
const furnished = countMeshes(scene);
|
||||
assert.ok(furnished >= 2,
|
||||
`${v.name}: only ${furnished} mesh(es) after update with an actor — furniture did not build`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function countMeshes(root) {
|
||||
let n = 0;
|
||||
root.traverse((o) => { if (o.isMesh) n++; });
|
||||
return n;
|
||||
}
|
||||
|
||||
// NOTE: ArcDriver persistence/interpolation/jump logic is exercised in the
|
||||
// browser (checks + filmstrip), not here: ArcDriver's import chain reads
|
||||
// scenes/metadata.json, which Node 20 cannot import without an attribute.
|
||||
// See tools/venue-check.js --filmstrip for the carryover measurement.
|
||||
|
||||
// Dynamic import indirection so a missing/broken three surfaces as a test
|
||||
// failure with a name on it rather than a top-level load error for the file.
|
||||
function awaitImportThree() {
|
||||
return import('three');
|
||||
}
|
||||
@@ -39,7 +39,7 @@ const FORBIDDEN = [
|
||||
];
|
||||
|
||||
// Directories whose output must be a pure function of (seed, params, frame).
|
||||
const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params', 'actors'];
|
||||
const DETERMINISTIC_DIRS = ['engine', 'scenes', 'look', 'audio', 'params', 'actors', 'venues'];
|
||||
// Files legitimately allowed a wall clock: perf measurement, not image content.
|
||||
const ALLOWED = new Set(['engine/perf.js']);
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
// Scaffold a new venue — mirrors tools/new-scene.js --model.
|
||||
// Venue IS the ground (role:'ground'), kind:'model' with build/update stubs
|
||||
// that map venueSpec.perSection + stageNode pattern.
|
||||
// Usage: node tools/new-venue.js "Harbour" --archetype=chamber
|
||||
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const VENUES = join(root, 'src/venues');
|
||||
const REGISTRY = join(root, 'src/venues/registry.js');
|
||||
const ARCHETYPES = ['chamber','stage','ride'];
|
||||
const args = process.argv.slice(2);
|
||||
const flags = new Map(args.filter(a=>a.startsWith('--')).map(a=>{ const [k,v]=a.replace(/^--/,'').split('='); return [k, v===undefined?true:v]; }));
|
||||
const name = args.find(a=>!a.startsWith('--'));
|
||||
if (!name) { console.error(`usage: node tools/new-venue.js "Name" --archetype=<${ARCHETYPES.join('|')}>`); process.exit(1); }
|
||||
const archetype = String(flags.get('archetype')||'chamber');
|
||||
if (!ARCHETYPES.includes(archetype)) { console.error(`unknown archetype '${archetype}'`); process.exit(1); }
|
||||
const kebab = name.trim().toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');
|
||||
const camel = kebab.replace(/-([a-z0-9])/g, (_,c)=>c.toUpperCase());
|
||||
const file = join(VENUES, `${kebab}.js`);
|
||||
if (existsSync(file)) { console.error(`${kebab}.js already exists`); process.exit(1); }
|
||||
const salt = (text, lo, hi)=>{ let h=2166136261; for(let i=0;i<text.length;i++){ h^=text.charCodeAt(i); h=Math.imul(h,16777619)>>>0; } return +(lo+(h%1000)/1000*(hi-lo)).toFixed(2); };
|
||||
const spread = salt(kebab, 0.5, 1.1);
|
||||
const light = salt(kebab+'light', 0.5, 0.9);
|
||||
const source = `// VENUE: ${name} — ${archetype} archetype. One venue = one place.
|
||||
// Primitives only in V1; per-section evolution via venueState {light,fog,popMul,reachMul} from VenueSpec.perSection.
|
||||
import { actorToGeometry } from '../actors/meshes.js';
|
||||
export const ${camel} = {
|
||||
name: '${name.trim()}',
|
||||
archetype: '${archetype}',
|
||||
family: '${archetype==='chamber'?'minimal':'structural'}',
|
||||
kind: 'model',
|
||||
venue: true,
|
||||
consumes: ['form','ink','staging'],
|
||||
traits: ['shape','space','style'],
|
||||
texture: 0.35,
|
||||
// How much viz bloom this room can take before its highlights clip.
|
||||
// Dim rooms (plaster, floor light) ~1.1; rooms with a big emissive screen ~0.55.
|
||||
reach: 0.9,
|
||||
params: {
|
||||
density: { type: 'float', range: [0.2, 0.9], default: ${spread}, uniform: 'u_density', bias: 'density' },
|
||||
light: { type: 'float', range: [0.3, 1.2], default: ${light}, uniform: 'u_light', bias: 'energy' },
|
||||
palette: { type: 'palette', count: 4 },
|
||||
},
|
||||
reactive: {},
|
||||
build({ scene, seed, params, actorSpec, venueSpec, THREE }) {
|
||||
const T = THREE;
|
||||
const group = new T.Group(); group.name='${kebab}';
|
||||
const mat = new T.MeshStandardMaterial({ color: new T.Color(0.14,0.13,0.12), roughness: 0.85 });
|
||||
const floor = new T.Mesh(new T.PlaneGeometry(22,22), mat);
|
||||
floor.rotation.x = -Math.PI/2; floor.position.y = -1.4; group.add(floor);
|
||||
scene.add(group);
|
||||
const amb = new T.AmbientLight(0xffffff, 0.42); scene.add(amb);
|
||||
scene.background = null; scene.fog = new T.Fog(0x0e0e12, 12, 28); scene.userData.transparentBackground = true;
|
||||
const pop = new T.Group(); pop.name='pop'; scene.add(pop);
|
||||
return { group, mat, amb, pop, seed, venueSpec: venueSpec||null, venueState: null, builtKey: null, popCount: 0 };
|
||||
},
|
||||
update({ instance, scene, camera, timeline, features, params, palette, personality, framing, opacity, actorSpec, venueSpec, venueState, THREE }) {
|
||||
const T = THREE; const t = timeline.time;
|
||||
const pal = palette && palette.length ? palette : [[0.9,0.9,0.92],[0.7,0.6,0.8]];
|
||||
const vs = venueSpec || instance.venueSpec;
|
||||
const vState = venueState || instance.venueState || null;
|
||||
if (vState) instance.venueState = vState;
|
||||
const c0 = pal[0];
|
||||
instance.mat.color.setRGB(c0[0]*0.26, c0[1]*0.24, c0[2]*0.28);
|
||||
if (scene.fog && vState) { const fogMul = vState.fog; const space = personality?.space; const base = space ? 12 + space.depth*6 : 15; scene.fog.near = base * (0.85 + fogMul*0.4); }
|
||||
if (vState) {
|
||||
const target = 0.55 * (params.light||0.7) * (0.75 + vState.light*0.5);
|
||||
// stub emissive if needed: venue surface placeholder
|
||||
void target;
|
||||
}
|
||||
const identity = personality?.identity || null;
|
||||
if (vs && identity && actorSpec && actorSpec.form && (!instance.popCount || instance.builtKey !== \`\${vs.seed}:\${vs.population.count}:\${identity.cast.protagonist.sides}:\${actorSpec.seed}\`)) {
|
||||
// rebuild pop on spec change — stub: real venue would populate via stageNode/actorToGeometry
|
||||
instance.builtKey = \`\${vs.seed}:\${vs.population.count}:\${identity.cast.protagonist.sides}:\${actorSpec.seed}\`;
|
||||
instance.popCount = vs.population.count;
|
||||
}
|
||||
// Gate visibility by popMul roughly
|
||||
if (vState) {
|
||||
const mul = vState.popMul; const floorCount = Math.max(1, Math.floor((instance.popCount||0) * (0.3 + mul*0.8)));
|
||||
for (let i=0;i<instance.pop.children.length;i++) instance.pop.children[i].visible = i < floorCount && opacity>0.01;
|
||||
}
|
||||
void T; void t; void params; void opacity;
|
||||
},
|
||||
};
|
||||
export default ${camel};
|
||||
`;
|
||||
writeFileSync(file, source);
|
||||
let registry = readFileSync(REGISTRY,'utf8');
|
||||
const importLine = `import { ${camel} } from './${kebab}.js';`;
|
||||
if (!registry.includes(importLine)) {
|
||||
const lastImport = registry.lastIndexOf("} from './");
|
||||
const eol = registry.indexOf('\n', lastImport);
|
||||
registry = registry.slice(0,eol+1)+importLine+'\n'+registry.slice(eol+1);
|
||||
}
|
||||
registry = registry.replace(/const MODULES = \[/, `const MODULES = [\n ${camel},`);
|
||||
writeFileSync(REGISTRY, registry);
|
||||
console.log(`created src/venues/${kebab}.js`);
|
||||
console.log(`registered ${camel} (${archetype})`);
|
||||
@@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env node
|
||||
// Venue gate loop driver — headless Chromium (SwiftShader WebGL2) against vite dev.
|
||||
// Renders as little as possible per step; every wait has a heartbeat log so a
|
||||
// hang is visible in seconds, not after a 15-minute timeout.
|
||||
//
|
||||
// Usage:
|
||||
// node tools/venue-check.js --quick fast gates, phases 9+12 only
|
||||
// node tools/venue-check.js --quick --phase=9 fast gates, one phase
|
||||
// node tools/venue-check.js --full + slow phase 12 + variety/songs/decompose (small N)
|
||||
// node tools/venue-check.js --filmstrip venue=1 filmstrip, 2 songs default
|
||||
// node tools/venue-check.js --filmstrip --song=centre one song
|
||||
// node tools/venue-check.js --filmstrip --venue=0 no-venue baseline for comparison
|
||||
// node tools/venue-check.js --gallery venue gallery, 2x2 default
|
||||
// node tools/venue-check.js --gallery --venue-sel=Chamber --viz=Kaleido\ Tunnel
|
||||
// node tools/venue-check.js --all everything, small defaults
|
||||
//
|
||||
// Flags:
|
||||
// --phase=9,12 phase filter for the checks steps (default quick: 9,12; full: 12)
|
||||
// --venue=1 filmstrip venue flag (default 1; use 0 for the no-venue baseline)
|
||||
// --venue-sel=NAME pin one venue in every gallery cell (default: census)
|
||||
// --viz=NAME pin one viz in every gallery cell (default: census)
|
||||
// --song=a,b filmstrip song subset (default: centre,drone)
|
||||
// --rows=N --cols=N gallery grid (default: 2x2)
|
||||
// --seeds=N variety/songs/decompose counts (default: 4)
|
||||
// --timeout=N seconds per wait (default: 150)
|
||||
// Exits non-zero if any gate fails. Screenshots to logs/venue/.
|
||||
import { chromium } from 'playwright-core';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(here, '..');
|
||||
const BASE = process.env.VENUE_BASE || 'http://127.0.0.1:5180';
|
||||
const OUT = resolve(root, 'logs/venue');
|
||||
mkdirSync(OUT, { recursive: true });
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const has = (n) => argv.includes(n);
|
||||
const arg = (n, fb) => {
|
||||
// Supports both `--flag value` and `--flag=value`.
|
||||
const eq = argv.find((a) => a.startsWith(`${n}=`));
|
||||
if (eq) return eq.slice(n.length + 1);
|
||||
const i = argv.indexOf(n);
|
||||
return i >= 0 && argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[i + 1] : fb;
|
||||
};
|
||||
const TIMEOUT_MS = Number(arg('--timeout', 150)) * 1000;
|
||||
const PHASE_QUICK = arg('--phase', '9,12');
|
||||
const PHASE_FULL = arg('--phase', '12');
|
||||
const SONGS = arg('--song', 'centre,drone');
|
||||
const VENUE_FLAG = arg('--venue', '1');
|
||||
const VENUE_SEL = arg('--venue-sel', '');
|
||||
const VIZ_SEL = arg('--viz', '');
|
||||
const ROWS = Number(arg('--rows', 2));
|
||||
const COLS = Number(arg('--cols', 2));
|
||||
const SEEDS = Number(arg('--seeds', 4));
|
||||
|
||||
const want = {
|
||||
quick: has('--quick') || has('--all') || (!has('--full') && !has('--filmstrip') && !has('--gallery')),
|
||||
full: has('--full') || has('--all'),
|
||||
filmstrip: has('--filmstrip') || has('--all'),
|
||||
gallery: has('--gallery') || has('--all'),
|
||||
};
|
||||
|
||||
const t0 = Date.now();
|
||||
const ts = () => `${((Date.now() - t0) / 1000).toFixed(0).padStart(4)}s`;
|
||||
const log = (...a) => console.log(`[${ts()}]`, ...a);
|
||||
|
||||
const results = { ok: true, steps: [] };
|
||||
function step(name, ok, detail) {
|
||||
results.steps.push({ name, ok, detail });
|
||||
if (!ok) results.ok = false;
|
||||
log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${String(detail).slice(0, 300)}` : ''}`);
|
||||
}
|
||||
|
||||
// Fail fast when the dev server is unreachable instead of hanging in goto.
|
||||
async function preflight() {
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), 10000);
|
||||
try {
|
||||
const r = await fetch(`${BASE}/checks.html`, { signal: ctl.signal });
|
||||
log(`preflight ${BASE} -> ${r.status}`);
|
||||
return r.ok;
|
||||
} catch (e) {
|
||||
log(`preflight ${BASE} UNREACHABLE: ${String(e && e.message || e).slice(0, 120)}`);
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// Poll `probe` (runs in page) until it reports {done:true}; heartbeat every 10s.
|
||||
async function waitDone(page, probe, label, timeoutMs = TIMEOUT_MS) {
|
||||
const start = Date.now();
|
||||
let lastBeat = 0;
|
||||
for (;;) {
|
||||
const st = await page.evaluate(`(${probe})()`).catch((e) => ({ evalFail: String(e).slice(0, 80) }));
|
||||
if (st && st.done) return st;
|
||||
const elapsed = Date.now() - start;
|
||||
if (elapsed > timeoutMs) {
|
||||
await page.screenshot({ path: resolve(OUT, `fail-${label}-${Date.now()}.png`) }).catch(() => {});
|
||||
throw new Error(`${label}: timeout after ${Math.round(elapsed / 1000)}s (last: ${JSON.stringify(st).slice(0, 200)})`);
|
||||
}
|
||||
if (elapsed - lastBeat > 10000) {
|
||||
lastBeat = elapsed;
|
||||
log(`${label}: +${Math.round(elapsed / 1000)}s`, JSON.stringify(st).slice(0, 160));
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
const checksProbe = `() => ({
|
||||
done: window.__CHECKS_DONE__ === true,
|
||||
info: (document.getElementById('summary')?.textContent || '').slice(0, 100)
|
||||
+ ' | rows=' + document.querySelectorAll('#results .row').length,
|
||||
})`;
|
||||
const filmProbe = `() => ({
|
||||
done: window.__FILMSTRIP_DONE__ === true,
|
||||
info: 'rows=' + document.querySelectorAll('#out .row').length
|
||||
+ ' ' + (document.getElementById('status')?.textContent || '').slice(0, 80),
|
||||
})`;
|
||||
const galleryProbe = `() => {
|
||||
const s = document.getElementById('status');
|
||||
const t = s ? s.textContent : '';
|
||||
return { done: /^done/.test(t), info: t.slice(0, 120)
|
||||
+ ' | cards=' + document.querySelectorAll('#out .card').length };
|
||||
}`;
|
||||
|
||||
let browser = null;
|
||||
try {
|
||||
log(`args phases=${PHASE_QUICK}/${PHASE_FULL} songs=${SONGS} venue=${VENUE_FLAG} grid=${ROWS}x${COLS} seeds=${SEEDS} timeout=${TIMEOUT_MS / 1000}s base=${BASE}${VENUE_SEL ? ` venueSel=${VENUE_SEL}` : ''}${VIZ_SEL ? ` viz=${VIZ_SEL}` : ''}`);
|
||||
if (!await preflight()) throw new Error('dev server unreachable — start `npm run dev` first');
|
||||
|
||||
log('launching chromium (headless shell, swiftshader, --no-sandbox)…');
|
||||
browser = await chromium.launch({ args: ['--no-sandbox', '--disable-gpu', '--enable-unsafe-swiftshader'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
page.setDefaultTimeout(TIMEOUT_MS);
|
||||
page.setDefaultNavigationTimeout(30000);
|
||||
page.on('pageerror', (e) => log('[pageerror]', String(e && e.message || e).slice(0, 200)));
|
||||
page.on('console', (m) => {
|
||||
if (m.type() === 'error') log('[console.error]', m.text().slice(0, 200));
|
||||
else if (m.type() === 'warning' && /WebGL/i.test(m.text())) log('[console.webgl]', m.text().slice(0, 160));
|
||||
});
|
||||
page.on('requestfailed', (r) => log('[reqfail]', r.url().slice(0, 100), r.failure()?.errorText || ''));
|
||||
|
||||
async function gotoChecks(query, label) {
|
||||
const url = `${BASE}/checks.html?${query}`;
|
||||
log(`${label}: goto ${query}`);
|
||||
await page.goto(url, { waitUntil: 'commit' }); // 'load' never fires while GL contexts are held
|
||||
const st = await waitDone(page, checksProbe, label);
|
||||
log(`${label}: finished — ${st.info}`);
|
||||
return page.evaluate(() => window.__CHECKS__);
|
||||
}
|
||||
|
||||
if (want.quick) {
|
||||
const label = 'quick';
|
||||
const c = await gotoChecks(`slow=0&phase=${PHASE_QUICK}`, label);
|
||||
const failed = (c.results || []).filter((r) => !r.pass && !r.manual);
|
||||
step(`fast gates (phases ${PHASE_QUICK})`, failed.length === 0,
|
||||
`${c.summary.passed}/${c.summary.total} passed` +
|
||||
(failed.length ? ` · FAIL: ${failed.map((r) => `P${r.phase}/${r.name} — ${(r.detail || '').slice(0, 120)}`).join(' · ')}` : ''));
|
||||
}
|
||||
if (want.full) {
|
||||
const c = await gotoChecks(`phase=${PHASE_FULL}&slow=1`, 'full-p12');
|
||||
const failed = (c.results || []).filter((r) => !r.pass && !r.manual);
|
||||
step(`slow phase ${PHASE_FULL}`, failed.length === 0,
|
||||
failed.length ? `FAIL: ${failed.map((r) => `${r.name}: ${(r.detail || '').slice(0, 150)}`).join(' · ')}`
|
||||
: `${c.summary.passed}/${c.summary.total} passed`);
|
||||
for (const q of [`variety=1&seeds=${SEEDS}&library=0`, `songs=1&count=${SEEDS}`, `decompose=1&count=${SEEDS}`]) {
|
||||
const r = await gotoChecks(q, `full-${q.split('&')[0].split('=')[0]}`);
|
||||
step(`checks ${q.split('&')[0]}`, !!r.ok, (r.headline || r.lines?.slice(-3).join(' | ') || '').slice(0, 200));
|
||||
}
|
||||
}
|
||||
if (want.filmstrip) {
|
||||
const label = `filmstrip-v${VENUE_FLAG}`;
|
||||
const EVERY = arg('--every', '30');
|
||||
const DUR = arg('--duration', '180');
|
||||
const url = `${BASE}/filmstrip.html?venue=${VENUE_FLAG}&song=${encodeURIComponent(SONGS)}&every=${EVERY}&duration=${DUR}`;
|
||||
log(`${label}: goto venue=${VENUE_FLAG} song=${SONGS} every=${EVERY} duration=${DUR}`);
|
||||
await page.goto(url, { waitUntil: 'commit' });
|
||||
await waitDone(page, filmProbe, label);
|
||||
const rows = await page.evaluate(() => window.__FILMSTRIP__.rows.map((r) => ({
|
||||
name: r.name, direction: +r.direction?.toFixed(3), drift: +r.drift?.toFixed(3),
|
||||
agitation: +r.agitation?.toFixed(3), error: r.error || null,
|
||||
venue: r.look?.venue ? `${r.look.venue.name}(${r.look.venue.archetype})` : 'none',
|
||||
director: r.director || '?',
|
||||
// Per-probe story: what was on screen when each frame was taken.
|
||||
probes: (r.shots || r.frames || []).map((f) => {
|
||||
const t = Math.round(f.time);
|
||||
const mm = `${Math.floor(t / 60)}:${String(t % 60).padStart(2, '0')}`;
|
||||
return `${mm} ${f.kind}/${f.scene}${f.moment ? `:${f.moment}` : ''} j${(+f.journey || 0).toFixed(2)}`;
|
||||
}),
|
||||
})));
|
||||
const flat = rows.filter((r) => !r.error && r.direction < 0.15);
|
||||
const agitated = rows.filter((r) => (r.agitation ?? 0) >= 0.70);
|
||||
const broken = rows.filter((r) => r.error);
|
||||
step(`filmstrip venue=${VENUE_FLAG} (${SONGS})`, flat.length === 0 && broken.length === 0,
|
||||
`${rows.length} songs · flat: ${flat.map((r) => r.name).join(',') || 'none'}` +
|
||||
` · agitated: ${agitated.map((r) => r.name).join(',') || 'none'}` +
|
||||
(broken.length ? ` · BROKEN: ${broken.map((r) => r.name).join(',')}` : ''));
|
||||
for (const r of rows) {
|
||||
log(` ${r.name.padEnd(8)} dir ${r.direction} drift ${r.drift} agit ${r.agitation} [${r.venue} · ${r.director}]${r.error ? ' ERR ' + r.error : ''}`);
|
||||
for (const p of r.probes || []) log(` ${r.name.padEnd(8)} ${p}`);
|
||||
}
|
||||
}
|
||||
if (want.gallery) {
|
||||
const label = 'gallery';
|
||||
const qp = new URLSearchParams({ rows: String(ROWS), cols: String(COLS) });
|
||||
if (VENUE_SEL) qp.set('venueSel', VENUE_SEL);
|
||||
if (VIZ_SEL) qp.set('viz', VIZ_SEL);
|
||||
const url = `${BASE}/venue-gallery.html?${qp}`;
|
||||
log(`${label}: goto rows=${ROWS} cols=${COLS}`);
|
||||
await page.goto(url, { waitUntil: 'commit' });
|
||||
const st = await waitDone(page, galleryProbe, label);
|
||||
log(`${label}: finished — ${st.info}`);
|
||||
const shot = resolve(OUT, `venue-gallery-${Date.now()}.png`);
|
||||
await page.screenshot({ path: shot, fullPage: true });
|
||||
const meta = await page.evaluate(() => [...document.querySelectorAll('#out .card .meta')].map((e) => e.textContent));
|
||||
const venuesSeen = [...new Set(meta.map((m) => m.split('·')[0]?.trim()))];
|
||||
step(`venue gallery ${ROWS}x${COLS} census`, venuesSeen.length >= 2,
|
||||
`${meta.length} cells · venues: ${venuesSeen.join(', ')} · shot ${shot}`);
|
||||
}
|
||||
} catch (err) {
|
||||
step('harness', false, String(err && err.stack || err).slice(0, 300));
|
||||
} finally {
|
||||
await browser?.close().catch(() => {});
|
||||
log('browser closed');
|
||||
}
|
||||
|
||||
log(JSON.stringify({ ok: results.ok, steps: results.steps.map((s) => ({ name: s.name, ok: s.ok })) }));
|
||||
process.exit(results.ok ? 0 : 1);
|
||||
@@ -0,0 +1,373 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>flow-state · venue gallery</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin: 0; padding: 20px 24px 60px; background:#0b0d12; color:#d6dae3; font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||||
h1 { font-size:15px; letter-spacing:.12em; text-transform:uppercase; color:#7d8698; margin:0 0 4px; }
|
||||
a { color:#7dd3fc; }
|
||||
.lede { color:#8b94a7; max-width:84ch; margin:0 0 14px; }
|
||||
.bar { position:sticky; top:0; z-index:5; background:#0b0d12; padding:10px 0 12px; border-bottom:1px solid #1c2030; margin-bottom:16px; }
|
||||
button, select { background:#171b24; color:#d6dae3; border:1px solid #2a3040; padding:5px 10px; font:inherit; border-radius:3px; cursor:pointer; }
|
||||
button:hover:not(:disabled){ border-color:#4a5468; }
|
||||
button:disabled{ opacity:.5; cursor:default; }
|
||||
label.ctl { color:#6b7280; font-size:12px; display:inline-flex; gap:6px; align-items:center; }
|
||||
#status { color:#8b94a7; margin-left:10px; }
|
||||
#status.err { color:#f87171; }
|
||||
.grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap:10px; }
|
||||
.card { background:#11141b; border:1px solid #1c2030; border-radius:4px; padding:8px; }
|
||||
.card.error { border-color:#7f1d1d; }
|
||||
.card h3 { margin:0 0 4px; font-size:12px; color:#e6eaf2; }
|
||||
.meta { color:#6b7280; font-size:11px; }
|
||||
.meta.err { color:#f87171; }
|
||||
canvas { width:100%; display:block; background:#05070a; border-radius:2px; aspect-ratio:16/9; }
|
||||
.hint { font-size:10px; color:#565f70; margin-top:4px; }
|
||||
#controls { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-top:8px; }
|
||||
.progress { height:2px; background:#1a1e28; margin-top:8px; border-radius:2px; overflow:hidden; display:none; }
|
||||
.progress i { display:block; height:100%; width:0; background:#7dd3fc; transition:width .2s; }
|
||||
.building .progress{ display:block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>flow-state · venue gallery</h1>
|
||||
<p class="lede">
|
||||
Venue × viz explorer. Venue is the track's place (higher-level than viz, picked first via census);
|
||||
viz is the scene inside it. Grid is <strong>songs (rows) × seeds (cols)</strong> — each column is
|
||||
a fresh seeded draw of the same song, so venue and viz vary by census across columns when left on
|
||||
auto. <strong>Render re-renders the grid</strong>; narrow it with the dropdowns first.
|
||||
A sad slow chamber of memories vs. a pumping stage field, same songs, side by side.
|
||||
<a href="/debug.html">← all debug tools</a> · <a href="/?venue=1">open app with venue</a>
|
||||
</p>
|
||||
<div class="bar" id="bar">
|
||||
<div id="controls">
|
||||
<select id="sel-venue" title="pin one venue in every cell, or census"></select>
|
||||
<select id="sel-viz" title="pin one viz as the subject in every cell, or census"></select>
|
||||
<select id="sel-song" title="song filter — one song, or all"></select>
|
||||
<label class="ctl">rows <select id="sel-rows" title="songs (rows)"></select></label>
|
||||
<label class="ctl">cols <select id="sel-cols" title="seeds per song (columns)"></select></label>
|
||||
<button id="btn-render">render</button>
|
||||
<button id="btn-randomize">random viz</button>
|
||||
<span id="status">starting…</span>
|
||||
</div>
|
||||
<div class="progress"><i></i></div>
|
||||
</div>
|
||||
<div id="out" class="grid"></div>
|
||||
<script type="module">
|
||||
import { Show } from '/src/Show.js';
|
||||
import { SONGS, song } from '/src/audio/songbank.js';
|
||||
import { Rng, hashString } from '/src/engine/rng.js';
|
||||
import { generateLook } from '/src/look/LookGenerator.js';
|
||||
import { sampleValues } from '/src/params/schema.js';
|
||||
import { subjectIndexOf } from '/src/look/stack.js';
|
||||
import { venues } from '/src/venues/registry.js';
|
||||
import { scenes } from '/src/scenes/registry.js';
|
||||
import { blit } from '/src/checks/blit.js';
|
||||
|
||||
const THUMB = { width: 320, height: 180 };
|
||||
const DEFAULT_ROWS = 5;
|
||||
const DEFAULT_COLS = 5;
|
||||
const ROW_OPTIONS = [2, 3, 4, 5, 6, 8, 10];
|
||||
const COL_OPTIONS = [1, 2, 3, 4, 5, 6, 8];
|
||||
|
||||
// Evenly spaced subset of the bank, same rule as songBank({count}) — a cheap
|
||||
// run still spans slow/quiet through fast/dense instead of testing N drones.
|
||||
function gridSongs(n) {
|
||||
const count = Math.max(1, Math.min(n, SONGS.length));
|
||||
if (count >= SONGS.length) return SONGS.map(s => s.name);
|
||||
return Array.from(
|
||||
{ length: count },
|
||||
(_, i) => SONGS[Math.round(i * (SONGS.length - 1) / (count - 1))].name,
|
||||
);
|
||||
}
|
||||
|
||||
const bar = document.getElementById('bar');
|
||||
const selVenue = document.getElementById('sel-venue');
|
||||
const selViz = document.getElementById('sel-viz');
|
||||
const selSong = document.getElementById('sel-song');
|
||||
const selRows = document.getElementById('sel-rows');
|
||||
const selCols = document.getElementById('sel-cols');
|
||||
const btnRender = document.getElementById('btn-render');
|
||||
const btnRandom = document.getElementById('btn-randomize');
|
||||
const out = document.getElementById('out');
|
||||
const status = document.getElementById('status');
|
||||
const progress = document.querySelector('.progress i');
|
||||
|
||||
const bankCache = new Map(); // song name -> track (synthesised once, reused)
|
||||
let building = false;
|
||||
|
||||
function setStatus(msg, isErr = false) {
|
||||
status.textContent = msg;
|
||||
status.classList.toggle('err', !!isErr);
|
||||
}
|
||||
|
||||
function setBusy(b) {
|
||||
building = b;
|
||||
bar.classList.toggle('building', b);
|
||||
btnRender.disabled = b;
|
||||
btnRandom.disabled = b;
|
||||
selVenue.disabled = b;
|
||||
selViz.disabled = b;
|
||||
selSong.disabled = b;
|
||||
selRows.disabled = b;
|
||||
selCols.disabled = b;
|
||||
if (b) progress.style.width = '0%';
|
||||
}
|
||||
|
||||
// Surface module-load and runtime failures in the page, not just the console:
|
||||
// a blank grid with "ready" in the corner is the failure this file exists to
|
||||
// prevent. Show construction (no WebGL2) throws before any card exists.
|
||||
window.addEventListener('error', (e) => {
|
||||
if (e && e.message && /WebGL2 unavailable/.test(e.message)) setStatus(e.message, true);
|
||||
});
|
||||
window.addEventListener('unhandledrejection', (e) => {
|
||||
const r = e && e.reason;
|
||||
const msg = r && r.message ? r.message : String(r);
|
||||
if (/WebGL2 unavailable/.test(msg)) setStatus(msg, true);
|
||||
});
|
||||
|
||||
function populate() {
|
||||
selVenue.innerHTML = `<option value="">all venues (census)</option>` +
|
||||
venues.map(v => `<option value="${v.name}">${v.name} (${v.archetype})</option>`).join('');
|
||||
selViz.innerHTML = `<option value="">auto viz (census)</option>` +
|
||||
scenes.filter(s => !s.venue).map(s => s.name).sort((a, b) => a.localeCompare(b))
|
||||
.map(n => `<option value="${n}">${n}</option>`).join('');
|
||||
selSong.innerHTML = `<option value="">all songs</option>` +
|
||||
SONGS.map(s => `<option value="${s.name}">${s.name}</option>`).join('');
|
||||
selRows.innerHTML = ROW_OPTIONS.map(n => `<option value="${n}">${n}</option>`).join('');
|
||||
selCols.innerHTML = COL_OPTIONS.map(n => `<option value="${n}">${n}</option>`).join('');
|
||||
selRows.value = String(DEFAULT_ROWS);
|
||||
selCols.value = String(DEFAULT_COLS);
|
||||
const q = new URLSearchParams(location.search);
|
||||
const take = (el, v) => { if (v && [...el.options].some(o => o.value === v)) el.value = v; };
|
||||
take(selVenue, q.get('venueSel'));
|
||||
take(selViz, q.get('viz'));
|
||||
take(selSong, q.get('song') ? q.get('song').split(',')[0] : null);
|
||||
take(selRows, q.get('rows'));
|
||||
take(selCols, q.get('cols'));
|
||||
}
|
||||
|
||||
function pushQuery() {
|
||||
const p = new URLSearchParams();
|
||||
if (selVenue.value) p.set('venueSel', selVenue.value);
|
||||
if (selViz.value) p.set('viz', selViz.value);
|
||||
if (selSong.value) p.set('song', selSong.value);
|
||||
p.set('rows', selRows.value);
|
||||
p.set('cols', selCols.value);
|
||||
history.replaceState(null, '', `?${p}`);
|
||||
}
|
||||
|
||||
async function ensureSongs(names) {
|
||||
const out = [];
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
if (!bankCache.has(names[i])) {
|
||||
setStatus(`synthesising song ${i + 1}/${names.length} · ${names[i]}…`);
|
||||
// Yield so the status paints: synthesising a 120 s track blocks the
|
||||
// main thread for seconds, otherwise the page looks dead.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
bankCache.set(names[i], song(names[i]).track);
|
||||
}
|
||||
out.push({ name: names[i], track: bankCache.get(names[i]) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Pin one viz as the subject of every section's stacks, keeping the venue
|
||||
// ground (and overlays) untouched. Mutates the look before Show.useTrack, so
|
||||
// the ArcDriver builds its layer cache from the pinned stacks directly.
|
||||
function pinViz(look, viz, seedBase) {
|
||||
const temper = look.personality && look.personality.temperament;
|
||||
for (const sec of look.sections) {
|
||||
const rng = new Rng(hashString(`${viz.name}:sec:${sec.index}:${seedBase >>> 0}`));
|
||||
for (let v = 0; v < (sec.variants || []).length; v++) {
|
||||
const stack = sec.variants[v];
|
||||
if (!stack) continue;
|
||||
const at = subjectIndexOf(stack);
|
||||
const cur = stack[at];
|
||||
if (!cur) continue;
|
||||
const params = sampleValues(viz, rng.fork(`variant:${v}`), sec.bias, temper);
|
||||
stack[at] = {
|
||||
module: viz,
|
||||
params,
|
||||
seed: rng.int(0, 0x7fffffff),
|
||||
// A model viz composites by alpha; a fragment viz needs the
|
||||
// key/screen path over the venue ground. Reuse the old blend
|
||||
// when it already fits, else fall back to the compositor-safe one.
|
||||
blend: viz.kind === 'model' ? 'normal'
|
||||
: (cur.blend === 'normal' ? 'lumakey' : cur.blend),
|
||||
opacity: cur.opacity ?? 1,
|
||||
blaze: cur.blaze ?? false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function errorCard(title, detail) {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card error';
|
||||
const h = document.createElement('h3');
|
||||
h.textContent = title;
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'meta err';
|
||||
meta.textContent = String(detail).slice(0, 500);
|
||||
card.appendChild(h);
|
||||
card.appendChild(meta);
|
||||
out.appendChild(card);
|
||||
}
|
||||
|
||||
async function renderCell(show, entry, venueName, vizMod, colIdx) {
|
||||
const seedBase = hashString(`${entry.name}:${venueName || 'auto'}:${vizMod ? vizMod.name : 'auto'}:col:${colIdx}`);
|
||||
let look;
|
||||
try {
|
||||
look = generateLook(entry.track, { seed: seedBase, venueEnabled: true });
|
||||
} catch (err) {
|
||||
errorCard(`${entry.name} · col ${colIdx + 1}`, `generateLook failed: ${err.message || err}`);
|
||||
return;
|
||||
}
|
||||
// Force the selected venue. The VenueSpec population stays the census one
|
||||
// (counts overlap between archetypes), so this is a shell swap for the lab.
|
||||
if (venueName) {
|
||||
const picked = venues.find(v => v.name === venueName);
|
||||
if (!picked) {
|
||||
errorCard(`${entry.name} · col ${colIdx + 1}`, `unknown venue "${venueName}"`);
|
||||
return;
|
||||
}
|
||||
if (!look.venue) {
|
||||
look.venue = {
|
||||
archetype: picked.archetype, name: picked.name, seed: seedBase,
|
||||
shell: { kind: picked.archetype }, projection: { kind: 'screen' },
|
||||
population: { kind: 'scatter', count: 5, spread: 0.7, jitter: 0.25 },
|
||||
paletteMap: [0, 1, 2, 3],
|
||||
perSection: look.sections.map(() => ({ light: 0.7, fog: 0.35, popMul: 0.65, reachMul: 0.85 })),
|
||||
venue: true, readsDepth: false, module: picked,
|
||||
};
|
||||
} else {
|
||||
look.venue.module = picked;
|
||||
look.venue.name = picked.name;
|
||||
look.venue.archetype = picked.archetype;
|
||||
}
|
||||
}
|
||||
if (vizMod) pinViz(look, vizMod, seedBase);
|
||||
|
||||
const fps = entry.track.fps || 60;
|
||||
const frame = Math.min(
|
||||
entry.track.frameCount - 2,
|
||||
Math.floor(entry.track.duration * 0.35 * fps),
|
||||
);
|
||||
try {
|
||||
show.useTrack(entry.track, look);
|
||||
} catch (err) {
|
||||
errorCard(`${entry.name} · col ${colIdx + 1}`, `useTrack failed: ${err.message || err}`);
|
||||
return;
|
||||
}
|
||||
show.warmUp(frame, 24);
|
||||
let target;
|
||||
try {
|
||||
target = show.renderFrame(frame);
|
||||
} catch (err) {
|
||||
errorCard(`${entry.name} · col ${colIdx + 1}`, `renderFrame failed: ${err.message || err}`);
|
||||
return;
|
||||
}
|
||||
const pix = Uint8Array.from(show.readPixels(target));
|
||||
const venueLabel = look.venue ? look.venue.name : 'no venue';
|
||||
const shownViz = vizMod ? vizMod.name : (show.arc && show.arc.state ? show.arc.state.sceneName : '?');
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
const h = document.createElement('h3');
|
||||
h.textContent = `${entry.name} · ${venueLabel}`;
|
||||
const cv = document.createElement('canvas');
|
||||
cv.width = THUMB.width; cv.height = THUMB.height;
|
||||
const meta = document.createElement('div');
|
||||
meta.className = 'meta';
|
||||
meta.textContent = `${look.venue ? look.venue.archetype : ''} · viz ${shownViz} · seed ${seedBase >>> 0}`;
|
||||
card.appendChild(h);
|
||||
card.appendChild(cv);
|
||||
card.appendChild(meta);
|
||||
out.appendChild(card);
|
||||
blit(cv, pix, THUMB.width, THUMB.height);
|
||||
}
|
||||
|
||||
// Render (or re-render) the whole grid: songs (rows) × seeds (cols), one
|
||||
// shared Show, cards cleared first so Render never appends.
|
||||
async function renderGrid() {
|
||||
if (building) return;
|
||||
setBusy(true);
|
||||
out.innerHTML = '';
|
||||
try {
|
||||
pushQuery();
|
||||
const rows = Math.max(1, Number(selRows.value) || DEFAULT_ROWS);
|
||||
const cols = Math.max(1, Number(selCols.value) || DEFAULT_COLS);
|
||||
const songNames = selSong.value ? [selSong.value] : gridSongs(rows);
|
||||
const entries = await ensureSongs(songNames);
|
||||
const vizMod = selViz.value ? scenes.find(s => s.name === selViz.value) || null : null;
|
||||
if (vizMod && vizMod.venue) {
|
||||
setStatus(`"${vizMod.name}" is a venue, not a viz — pick a scene`, true);
|
||||
return;
|
||||
}
|
||||
let show;
|
||||
try {
|
||||
show = new Show({ ...THUMB });
|
||||
} catch (err) {
|
||||
// Renderer throws a message that already names the cause (WebGL2)
|
||||
// and the fix (enable it / use Chromium+SwiftShader).
|
||||
setStatus(`cannot render: ${err.message || err}`, true);
|
||||
errorCard('renderer failed to start', err.message || err);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const total = entries.length * cols;
|
||||
let done = 0;
|
||||
// A single bad song (bad synth params, COMPOSITION_REFUSED) must
|
||||
// not silently end the grid at row one: guard per row, count the
|
||||
// miss, and keep rendering. The card names the failure.
|
||||
for (const entry of entries) {
|
||||
for (let c = 0; c < cols; c++) {
|
||||
setStatus(`rendering ${done + 1}/${total} · ${entry.name} · seed ${c + 1}/${cols}` +
|
||||
`${selVenue.value ? ` · ${selVenue.value}` : ''}${vizMod ? ` · ${vizMod.name}` : ''}…`);
|
||||
try {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await renderCell(show, entry, selVenue.value || null, vizMod, c);
|
||||
} catch (err) {
|
||||
errorCard(`${entry.name} · col ${c + 1}`,
|
||||
`cell failed and would previously have stopped the grid: ${err.message || err}`);
|
||||
}
|
||||
done += 1;
|
||||
progress.style.width = `${(done / total) * 100}%`;
|
||||
// Yield so each card paints as it lands instead of the
|
||||
// page freezing for the whole grid and showing it at once.
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
}
|
||||
setStatus(`done · ${done}/${total} cells · ${entries.length} song(s) × ${cols} seed(s)` +
|
||||
(selVenue.value ? ` · venue ${selVenue.value}` : ' · venues from census') +
|
||||
(vizMod ? ` · viz pinned to ${vizMod.name}` : ' · viz from census'));
|
||||
} finally {
|
||||
show.dispose();
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus(`grid failed: ${err.message || err}`, true);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
populate();
|
||||
btnRender.addEventListener('click', renderGrid);
|
||||
btnRandom.addEventListener('click', () => {
|
||||
const vizPool = scenes.filter(s => !s.venue);
|
||||
selViz.value = vizPool[Math.floor(Math.random() * vizPool.length)].name;
|
||||
renderGrid();
|
||||
});
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT')) return;
|
||||
if (e.key === 'Enter') renderGrid();
|
||||
if (e.key === 'r' && !e.metaKey && !e.ctrlKey) { e.preventDefault(); btnRandom.click(); }
|
||||
});
|
||||
// Default view is the grid itself, not an empty page waiting for a click.
|
||||
renderGrid();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -303,6 +303,7 @@ export default defineConfig({
|
||||
debug: resolve(process.cwd(), 'debug.html'),
|
||||
gallery: resolve(process.cwd(), 'gallery.html'),
|
||||
filmstrip: resolve(process.cwd(), 'filmstrip.html'),
|
||||
'venue-gallery': resolve(process.cwd(), 'venue-gallery.html'),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user