// 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;