Actors and objects change only at camera cuts. int/bool/discrete params skip drift, lookahead and reactivity; discrete slowAxis steps on the section-held journey. Venues fade instead of toggling on interpolated popMul; roadside density is arrangement-only. DESIGN.md collects the project's design decisions; AGENTS.md points agents at it and the build-visualizer loop.
769 lines
38 KiB
JavaScript
769 lines
38 KiB
JavaScript
// VENUE: Chamber — derelict room full of junk furniture.
|
|
//
|
|
// A sparse, still place. Window-light + floor reflections carry the viz
|
|
// tint; procedurally built junk (tables, chairs, cupboards, shelves with
|
|
// books, plants, box stacks) populates it, and the track's actors wander
|
|
// the room — visiting furniture, pausing to face it, meeting each other
|
|
// at the centre. Population density + light/fog evolve per Story act
|
|
// (setup sparse, climax bright, resolution dim); the arrangement salt
|
|
// re-hangs the room on story jumps. Primitives only — no glTF kit.
|
|
|
|
import { actorToGeometry } from '../actors/meshes.js';
|
|
import { buildSky, paintSky } from './sky.js';
|
|
|
|
// --- procedural junk furniture ------------------------------------------------
|
|
// Primitives only, palette-lit, seeded. Every builder returns { group, radius }
|
|
// with the group origin at floor level (+y up); radius is the footprint used
|
|
// by the repulsion passes. Deterministic in (rng): same draws, same junk.
|
|
|
|
function mulberry32(a) {
|
|
let s = a >>> 0;
|
|
return function () {
|
|
s |= 0; s = (s + 0x6D2B79F5) | 0;
|
|
let z = Math.imul(s ^ (s >>> 15), 1 | s);
|
|
z = (z + Math.imul(z ^ (z >>> 7), 61 | z)) ^ z;
|
|
return ((z ^ (z >>> 14)) >>> 0) / 4294967296;
|
|
};
|
|
}
|
|
|
|
// Faint emissive in the piece's own colour: in the darkest rooms (darkest
|
|
// songs pick the chamber by design) lit-only shapes go black and the
|
|
// traverse moves the camera through nothing visible. Kept low for junk,
|
|
// higher for actors — the figures are the subject and must pop out of
|
|
// their own room even in a monochrome palette.
|
|
function junkMat(T, cc, rough = 0.7, emis = 0.14) {
|
|
return new T.MeshStandardMaterial({
|
|
color: new T.Color(cc[0], cc[1], cc[2]),
|
|
emissive: new T.Color(cc[0] * emis, cc[1] * emis, cc[2] * emis),
|
|
emissiveIntensity: 1.0,
|
|
roughness: rough,
|
|
metalness: 0.06,
|
|
});
|
|
}
|
|
|
|
function junkBox(T, parent, mat, w, h, d, x, y, z, ry = 0, rz = 0) {
|
|
const m = new T.Mesh(new T.BoxGeometry(w, h, d), mat);
|
|
m.position.set(x, y, z);
|
|
m.rotation.y = ry;
|
|
m.rotation.z = rz;
|
|
m.castShadow = false;
|
|
m.receiveShadow = false;
|
|
parent.add(m);
|
|
return m;
|
|
}
|
|
|
|
function buildTable(T, rng, pal) {
|
|
const g = new T.Group();
|
|
const main = junkMat(T, pal[(rng() * pal.length) | 0], 0.75);
|
|
const dark = junkMat(T, pal[(rng() * pal.length) | 0], 0.85);
|
|
junkBox(T, g, main, 1.5, 0.09, 0.95, 0, 0.72, 0);
|
|
for (const sx of [-0.65, 0.65]) for (const sz of [-0.38, 0.38]) {
|
|
junkBox(T, g, dark, 0.08, 0.72, 0.08, sx, 0.36, sz);
|
|
}
|
|
return { group: g, radius: 0.95 };
|
|
}
|
|
|
|
function buildChair(T, rng, pal) {
|
|
const g = new T.Group();
|
|
const main = junkMat(T, pal[(rng() * pal.length) | 0], 0.7);
|
|
junkBox(T, g, main, 0.52, 0.07, 0.5, 0, 0.45, 0);
|
|
junkBox(T, g, main, 0.52, 0.6, 0.07, 0, 0.78, -0.245);
|
|
for (const sx of [-0.21, 0.21]) for (const sz of [-0.2, 0.2]) {
|
|
junkBox(T, g, main, 0.06, 0.45, 0.06, sx, 0.225, sz);
|
|
}
|
|
return { group: g, radius: 0.5 };
|
|
}
|
|
|
|
function buildCupboard(T, rng, pal) {
|
|
const g = new T.Group();
|
|
const main = junkMat(T, pal[(rng() * pal.length) | 0], 0.8);
|
|
const trim = junkMat(T, pal[(rng() * pal.length) | 0], 0.6);
|
|
junkBox(T, g, main, 1.15, 2.0, 0.55, 0, 1.0, 0);
|
|
junkBox(T, g, trim, 1.21, 0.08, 0.61, 0, 0.04, 0);
|
|
junkBox(T, g, trim, 1.21, 0.07, 0.61, 0, 2.03, 0);
|
|
for (const sx of [-0.27, 0.27]) {
|
|
junkBox(T, g, trim, 0.5, 1.7, 0.04, sx, 1.05, 0.28);
|
|
const knob = new T.Mesh(new T.SphereGeometry(0.035, 10, 8), trim);
|
|
knob.position.set(sx * 0.25, 1.05, 0.32);
|
|
g.add(knob);
|
|
}
|
|
return { group: g, radius: 0.75 };
|
|
}
|
|
|
|
function buildBooks(T, g, rng, pal, y, width) {
|
|
// One shelf-board of books: upright row, seeded widths/heights/colours,
|
|
// sometimes a leaner, sometimes a gap with a flat stack.
|
|
let x = -width / 2 + 0.08;
|
|
const end = width / 2 - 0.08;
|
|
while (x < end - 0.05) {
|
|
const r = rng();
|
|
if (r < 0.18 && x < end - 0.35) {
|
|
// Flat stack of 2-3.
|
|
const n = 2 + ((rng() * 2) | 0);
|
|
let sy = y;
|
|
for (let k = 0; k < n; k++) {
|
|
const bw = 0.3 + rng() * 0.12, bh = 0.055 + rng() * 0.02;
|
|
junkBox(T, g, junkMat(T, pal[(rng() * pal.length) | 0], 0.85),
|
|
bw, bh, 0.24, x + bw / 2, sy + bh / 2, 0, (rng() - 0.5) * 0.2);
|
|
sy += bh;
|
|
}
|
|
x += 0.48;
|
|
} else {
|
|
const bw = 0.05 + rng() * 0.05, bh = 0.28 + rng() * 0.14;
|
|
const lean = (rng() < 0.12 && x < end - 0.2) ? -0.16 : 0;
|
|
junkBox(T, g, junkMat(T, pal[(rng() * pal.length) | 0], 0.85),
|
|
bw, bh, 0.24, x + bw / 2, y + bh / 2, 0, 0, lean);
|
|
x += bw + 0.012 + (lean ? 0.06 : 0);
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildShelf(T, rng, pal) {
|
|
const g = new T.Group();
|
|
const main = junkMat(T, pal[(rng() * pal.length) | 0], 0.8);
|
|
junkBox(T, g, main, 0.06, 1.9, 0.42, -0.72, 0.95, 0);
|
|
junkBox(T, g, main, 0.06, 1.9, 0.42, 0.72, 0.95, 0);
|
|
junkBox(T, g, main, 1.5, 1.9, 0.03, 0, 0.95, -0.2);
|
|
const boards = [0.12, 0.72, 1.3, 1.87];
|
|
for (const y of boards) junkBox(T, g, main, 1.44, 0.06, 0.4, 0, y, 0);
|
|
for (const y of boards.slice(0, 3)) buildBooks(T, g, rng, pal, y + 0.03, 1.32);
|
|
return { group: g, radius: 0.85 };
|
|
}
|
|
|
|
function buildPlant(T, rng, pal) {
|
|
const g = new T.Group();
|
|
const cc = pal[(rng() * pal.length) | 0];
|
|
const pot = junkMat(T, [cc[0] * 0.6 + 0.08, cc[1] * 0.6 + 0.05, cc[2] * 0.6 + 0.04], 0.9);
|
|
// Foliage reads leafy in any palette: palette hue pulled toward green.
|
|
const leaf = junkMat(T,
|
|
[cc[0] * 0.35 + 0.1, cc[1] * 0.35 + 0.3, cc[2] * 0.35 + 0.1], 0.95);
|
|
const potMesh = new T.Mesh(new T.CylinderGeometry(0.24, 0.17, 0.42, 12), pot);
|
|
potMesh.position.y = 0.21;
|
|
g.add(potMesh);
|
|
const trunk = new T.Mesh(new T.CylinderGeometry(0.03, 0.045, 0.55, 8), pot);
|
|
trunk.position.y = 0.65;
|
|
g.add(trunk);
|
|
const blobs = 2 + ((rng() * 2) | 0);
|
|
for (let i = 0; i < blobs; i++) {
|
|
const r = 0.3 + rng() * 0.2;
|
|
const b = new T.Mesh(new T.IcosahedronGeometry(r, 0), leaf);
|
|
b.position.set((rng() - 0.5) * 0.5, 1.0 + i * 0.32 + rng() * 0.15, (rng() - 0.5) * 0.5);
|
|
b.rotation.set(rng() * 3, rng() * 3, rng() * 3);
|
|
g.add(b);
|
|
}
|
|
return { group: g, radius: 0.55 };
|
|
}
|
|
|
|
function buildBoxes(T, rng, pal) {
|
|
const g = new T.Group();
|
|
const n = 2 + ((rng() * 2) | 0);
|
|
let y = 0;
|
|
for (let i = 0; i < n; i++) {
|
|
const s = 0.55 + rng() * 0.3 - i * 0.06;
|
|
const h = 0.4 + rng() * 0.25;
|
|
junkBox(T, g, junkMat(T, pal[(rng() * pal.length) | 0], 0.9),
|
|
s, h, s * (0.8 + rng() * 0.4),
|
|
(rng() - 0.5) * 0.16, y + h / 2, (rng() - 0.5) * 0.16,
|
|
(rng() - 0.5) * 0.6);
|
|
y += h;
|
|
}
|
|
return { group: g, radius: 0.6 };
|
|
}
|
|
|
|
const JUNK_BUILDERS = [buildTable, buildChair, buildCupboard, buildShelf, buildPlant, buildBoxes];
|
|
|
|
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;
|
|
|
|
// Song sky above the open room — replaces clear-to-black. Radius 90
|
|
// clears the 22-unit room with margin; fog melts the horizon.
|
|
const sky = buildSky(T, { radius: 90 });
|
|
scene.add(sky.mesh);
|
|
|
|
// Population container — junk furniture
|
|
const popGroup = new T.Group();
|
|
popGroup.name = 'furniture';
|
|
scene.add(popGroup);
|
|
|
|
return {
|
|
room, floor, floorMat, wallMat, walls, windowMesh, windowMat,
|
|
reflection, reflectMat,
|
|
ambient, sun, fill,
|
|
skyMesh: sky.mesh, skyMat: sky.mat,
|
|
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; void shapeTilt; void styleWeight;
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
// Song sky + matched fog. The sky replaces clear-to-black; fog takes
|
|
// the dome's mid colour so the horizon melts instead of banding.
|
|
paintSky(instance.skyMat, {
|
|
palette: pal, light: roomLight, style,
|
|
seed: instance.seed,
|
|
});
|
|
// 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;
|
|
const mid = instance.skyMat ? instance.skyMat.uniforms.u_mid.value : null;
|
|
if (mid) scene.fog.color.copy(mid);
|
|
else 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 protagonistSides = identity && identity.cast ? identity.cast.protagonist.sides : 0;
|
|
const actorSeed = actorSpec ? actorSpec.seed || 0 : 0;
|
|
const popKey = vs ? `junk:${vs.seed}:${vs.population ? vs.population.count : 0}:p${protagonistSides}:s${actorSeed}:a${arrangement}` : `junk:no-vs:${instance.seed}:a${arrangement}`;
|
|
const needRebuild = instance.builtKey !== popKey || !instance.popCount;
|
|
if (needRebuild) {
|
|
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;
|
|
// Headcount split: 1-3 actors (the track's figures, always
|
|
// visible — they take the first indices so popMul gating never
|
|
// hides them while junk is still showing), the rest junk.
|
|
const actorCount = Math.min(3, Math.max(1, Math.round(count / 3)));
|
|
const junkCount = Math.max(1, count - actorCount);
|
|
// Junk dealt from a shuffled deck: the first slots always land
|
|
// distinct types (table, chair, cupboard, shelf, plant, boxes),
|
|
// then the deck cycles. Seeded by (seed, arrangement), so a
|
|
// re-hang deals different junk in different places — still
|
|
// deterministic per key, stable across seeks.
|
|
const rng = mulberry32(((instance.seed >>> 0)
|
|
^ Math.imul(arrangement + 1, 0x9E3779B9)) >>> 0);
|
|
const deck = JUNK_BUILDERS.map((_, i) => i);
|
|
for (let i = deck.length - 1; i > 0; i--) {
|
|
const j = (rng() * (i + 1)) | 0;
|
|
const tmp = deck[i]; deck[i] = deck[j]; deck[j] = tmp;
|
|
}
|
|
// Build pieces first — each reports its footprint radius, which
|
|
// the placement passes below need before positioning. Actors own
|
|
// indices 0..actorCount-1, so junk starts after them and the
|
|
// popMul sparse-setup gating keeps the figures while thinning
|
|
// the junk.
|
|
const pieces = [];
|
|
for (let j = 0; j < junkCount; j++) {
|
|
const built = JUNK_BUILDERS[deck[j % deck.length]](T, rng, pal);
|
|
built.group.userData.index = actorCount + j;
|
|
pieces.push(built);
|
|
}
|
|
// Deterministic positions — golden-angle spiral + separation passes.
|
|
// The spiral gives even coverage with an open middle for the
|
|
// camera; the relaxation below enforces per-pair gaps from the
|
|
// pieces' own footprint radii, so a cupboard never swallows a
|
|
// chair. Arrangement salt rotates the whole spiral, so a re-hang
|
|
// is a visibly new layout — deterministic per (seed,
|
|
// arrangement), stable across seeks.
|
|
const seeds = [];
|
|
for (let i = 0; i < junkCount; i++) {
|
|
const frac = (i + 0.5) / Math.max(1, junkCount);
|
|
const ang = i * 2.39996 + (instance.seed % 628) * 0.01 + arrangement * 2.39996 * 3;
|
|
const rad = (2.6 + 5.8 * 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≈0.4,
|
|
// and at furniture scale (1+ units wide) anything nearer than
|
|
// z≈-1.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(-1.5, rz));
|
|
seeds.push([rx, rz]);
|
|
}
|
|
// Repulsion passes: push overlapping pairs apart. Minimum gap from
|
|
// the pair's footprints — big pieces need more room. Deterministic,
|
|
// analytic, no wall-clock — same inputs always give same layout.
|
|
for (let pass = 0; pass < 12; pass++) {
|
|
for (let a = 0; a < junkCount; a++) {
|
|
for (let b = a + 1; b < junkCount; b++) {
|
|
const minDist = (pieces[a].radius + pieces[b].radius) * 0.7 + 0.6;
|
|
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(-1.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(-1.5, seeds[b][1] + nz * push));
|
|
}
|
|
}
|
|
}
|
|
for (let i = 0; i < junkCount; i++) {
|
|
const g = pieces[i].group;
|
|
g.position.set(seeds[i][0], -1.4, seeds[i][1]);
|
|
g.rotation.y = rng() * Math.PI * 2;
|
|
g.userData.baseY = -1.4;
|
|
g.userData.kind = 'junk';
|
|
instance.popGroup.add(g);
|
|
}
|
|
// Actors — the track's figures, built from the same monolith the
|
|
// viz hero uses (shared part geometries, own palette material per
|
|
// figure). Each gets two furniture anchors to visit plus a centre
|
|
// meeting point; per-frame motion walks that loop analytically
|
|
// below. Feet planted via bounding box; yaw faces whoever or
|
|
// whatever the figure is with.
|
|
let partGeos = null;
|
|
try {
|
|
if (identity && actorSpec && actorSpec.form) {
|
|
const body = actorToGeometry(actorSpec, identity, T);
|
|
partGeos = body.children
|
|
.filter((c) => c.geometry)
|
|
.map((c) => ({ geo: c.geometry, partIndex: c.userData.partIndex }));
|
|
}
|
|
} catch { partGeos = null; }
|
|
const clampRoom = (x, z) => [
|
|
Math.max(-9, Math.min(9, x)),
|
|
Math.max(-8.5, Math.min(-1.5, z)),
|
|
];
|
|
const actorCfg = [];
|
|
const actorColors = [];
|
|
for (let a = 0; a < actorCount; a++) {
|
|
const ag = new T.Group();
|
|
// Distinct palette entries per figure so the cast reads as
|
|
// individuals, not clones, even in narrow palettes.
|
|
let ci = (rng() * pal.length) | 0;
|
|
for (let guard = 0; guard < 4 && actorColors.includes(ci); guard++) {
|
|
ci = (rng() * pal.length) | 0;
|
|
}
|
|
actorColors.push(ci);
|
|
const acc = junkMat(T, pal[ci % pal.length], 0.5, 0.3);
|
|
if (partGeos && partGeos.length) {
|
|
for (const p of partGeos) {
|
|
const m = new T.Mesh(p.geo, acc);
|
|
m.userData.partIndex = p.partIndex;
|
|
m.castShadow = false;
|
|
m.receiveShadow = false;
|
|
ag.add(m);
|
|
}
|
|
} else {
|
|
// No actor to unwrap (hand-built looks, tests): a plain
|
|
// figure from primitives so the room still has its cast.
|
|
junkBox(T, ag, acc, 0.5, 0.9, 0.3, 0, 0.75, 0);
|
|
const head = new T.Mesh(new T.SphereGeometry(0.18, 12, 10), acc);
|
|
head.position.y = 1.38;
|
|
ag.add(head);
|
|
}
|
|
const s = 0.32 + rng() * 0.13;
|
|
ag.scale.setScalar(s);
|
|
ag.updateMatrixWorld(true);
|
|
const bbox = new T.Box3().setFromObject(ag);
|
|
const yBase = Number.isFinite(bbox.min.y) ? -1.4 - bbox.min.y : -1.4;
|
|
// Two distinct furniture anchors to visit (or the same twice
|
|
// when the room holds a single piece).
|
|
const kA = (rng() * junkCount) | 0;
|
|
let kB = (rng() * junkCount) | 0;
|
|
if (junkCount > 1) {
|
|
let guard = 0;
|
|
while (kB === kA && guard++ < 8) kB = (rng() * junkCount) | 0;
|
|
}
|
|
const standBeside = (k) => {
|
|
const ang = rng() * Math.PI * 2;
|
|
const dist = pieces[k].radius + 0.55;
|
|
const [sx, sz] = clampRoom(
|
|
seeds[k][0] + Math.cos(ang) * dist,
|
|
seeds[k][1] + Math.sin(ang) * dist);
|
|
return { stand: [sx, sz], anchor: [seeds[k][0], seeds[k][1]] };
|
|
};
|
|
const vA = standBeside(kA), vB = standBeside(kB);
|
|
const cx = (rng() - 0.5) * 4;
|
|
const cz = Math.max(-8.5, Math.min(-1.5, -3.5 + (rng() - 0.5) * 1.5));
|
|
ag.position.set(vA.stand[0], yBase, vA.stand[1]);
|
|
ag.userData.index = a;
|
|
ag.userData.kind = 'actor';
|
|
ag.userData.baseY = yBase;
|
|
instance.popGroup.add(ag);
|
|
actorCfg.push({
|
|
group: ag, yBase,
|
|
period: 40 + rng() * 25,
|
|
phase: rng() * 40,
|
|
standA: vA.stand, anchorA: vA.anchor,
|
|
standB: vB.stand, anchorB: vB.anchor,
|
|
center: [cx, cz],
|
|
partner: (a + 1) % actorCount,
|
|
});
|
|
}
|
|
instance.actorCfg = actorCfg;
|
|
instance.builtKey = popKey;
|
|
instance.popCount = count;
|
|
}
|
|
|
|
// Actors walk a closed loop — stand at furniture A facing it, meet at
|
|
// the centre facing their partner, stand at furniture B facing it,
|
|
// back to centre — on a slow seeded period with unsynced phases.
|
|
// Smootherstep legs start and end at rest, so figures pause, look,
|
|
// then move: visiting, not patrolling. Facing blends toward the next
|
|
// leg's target, so turns are smooth. Everything is f(t): positions
|
|
// first for all figures, then yaws (a figure faces its partner's
|
|
// same-frame position). Junk stands still — it is furniture.
|
|
// Nothing here integrates: a seek lands on the same room as playback.
|
|
const actors = instance.actorCfg || [];
|
|
const smoother = (u) => { const c = Math.max(0, Math.min(1, u)); return c * c * c * (c * (c * 6 - 15) + 10); };
|
|
const actorPos = actors.map((cfg) => {
|
|
const u = ((((t + cfg.phase) % cfg.period) + cfg.period) % cfg.period) / cfg.period;
|
|
const leg = Math.min(3, Math.floor(u * 4));
|
|
const e = smoother(u * 4 - leg);
|
|
const W = [cfg.standA, cfg.center, cfg.standB, cfg.center];
|
|
const p0 = W[leg], p1 = W[(leg + 1) % 4];
|
|
return [p0[0] + (p1[0] - p0[0]) * e, p0[1] + (p1[1] - p0[1]) * e, e, leg];
|
|
});
|
|
actors.forEach((cfg, i) => {
|
|
const g = cfg.group;
|
|
const px = actorPos[i][0], pz = actorPos[i][1];
|
|
const e = actorPos[i][2], leg = actorPos[i][3];
|
|
g.position.set(px, cfg.yBase + 0.02 + Math.sin(t * 0.9 + cfg.phase) * 0.03, pz);
|
|
// Face targets per waypoint: the visited furniture, or the
|
|
// partner's live position at the centre (a lone figure watches
|
|
// the window instead). Blend across the leg boundary for turns.
|
|
const solo = actors.length < 2;
|
|
const partnerPos = solo ? [0, -10] : actorPos[cfg.partner].slice(0, 2);
|
|
const F = [cfg.anchorA, partnerPos, cfg.anchorB, partnerPos];
|
|
const f0 = F[leg], f1 = F[(leg + 1) % 4];
|
|
const fx = f0[0] + (f1[0] - f0[0]) * e, fz = f0[1] + (f1[1] - f0[1]) * e;
|
|
if (Math.hypot(fx - px, fz - pz) > 1e-3) g.rotation.y = Math.atan2(fx - px, fz - pz);
|
|
});
|
|
for (const g of instance.popGroup.children) {
|
|
const idx = g.userData.index || 0;
|
|
const isActor = g.userData.kind === 'actor';
|
|
// Actors are the subject: always dressed. Junk thins out in
|
|
// sparse setups via popMul — as a SMOOTH opacity fade, never a
|
|
// visible toggle: the interpolated venueState crosses thresholds
|
|
// at arbitrary mid-section moments, and hard cuts there read as
|
|
// random flashes (same pattern as the ride's crew). Actors take
|
|
// the first indices so the fade never touches them.
|
|
const popMul = vState ? vState.popMul : 1;
|
|
const showIndex = idx / Math.max(1, instance.popCount);
|
|
const fade = isActor ? 1
|
|
: Math.max(0, Math.min(1, ((0.35 + popMul * 0.75) - showIndex) / 0.12));
|
|
g.visible = opacity > 0.01 && fade > 0.01;
|
|
g.traverse((mesh) => {
|
|
if (mesh.isMesh) {
|
|
mesh.material.opacity = opacity * fade;
|
|
mesh.material.transparent = opacity * fade < 0.999;
|
|
// The room's population breathes with the story arc: dim in
|
|
// setup, bright at the climax. Pure f(venueState), no clock.
|
|
// Figures breathe brighter — they must read against their
|
|
// own room even when the palette runs monochrome.
|
|
if (vState && mesh.material.emissiveIntensity !== undefined) {
|
|
mesh.material.emissiveIntensity = isActor
|
|
? 0.9 + vState.light * 1.4
|
|
: 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;
|