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:
Dejvino
2026-09-17 19:35:26 +02:00
parent b1b6431eeb
commit 46fab512df
31 changed files with 3684 additions and 358 deletions
+484
View File
@@ -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;