Chamber: procedural junk furniture instead of actor-part memories

Tables, chairs, cupboards, shelves with seeded book rows, potted plants
and box stacks built from primitives, dealt from a shuffled deck per
(seed, arrangement) so the first slots always land distinct types. Same
spiral + repulsion placement with per-item footprint radii; front clamp
pulled to z<=-1.5 for furniture scale so close cameras never wear a
table as a lens bar. Static (junk doesn't bob) with the existing
story-driven emissive breathe; re-hang on arrangement jumps moves
everything under the camera cut. popKey drops the actor dependence.

Verified: 24/24 tests, scene lint clean, same-frame re-render
bit-identical, rooms read as furnished across centre/drone/elegy.
Chamber-forced strips: elegy direction 0.26->0.61; centre stays
pre-existing-flat (0.11->0.09, dark song, same early arrangement);
drone agit unchanged (viz-driven).
This commit is contained in:
Dejvino
2026-09-19 19:08:50 +02:00
parent fd0cbe0553
commit 3b65ea905c
+221 -96
View File
@@ -1,21 +1,181 @@
// VENUE: Chamber — derelict room full of memories.
// VENUE: Chamber — derelict room full of junk furniture.
//
// 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.
// tint; procedurally built junk (tables, chairs, cupboards, shelves with
// books, plants, box stacks) populates it. 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;
};
}
function junkMat(T, cc, rough = 0.7) {
// Faint emissive in the piece's own colour: in the darkest rooms (darkest
// songs pick the chamber by design) lit-only junk goes black and the
// traverse moves the camera through nothing visible. Kept low — these are
// dusty things, not lamps.
return 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: 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'],
consumes: ['ink', 'staging'],
traits: ['shape', 'space', 'style'],
texture: 0.35,
// How much viz bloom this room can take before its highlights clip: the
@@ -218,9 +378,9 @@ export const chamber = {
const sky = buildSky(T, { radius: 90 });
scene.add(sky.mesh);
// Population container — memories
// Population container — junk furniture
const popGroup = new T.Group();
popGroup.name = 'memories';
popGroup.name = 'furniture';
scene.add(popGroup);
return {
@@ -337,9 +497,9 @@ export const chamber = {
// 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 popKey = vs ? `junk:${vs.seed}:${vs.population ? vs.population.count : 0}:a${arrangement}` : `junk:no-vs:${instance.seed}:a${arrangement}`;
const needRebuild = instance.builtKey !== popKey || !instance.popCount;
if (needRebuild && identity && actorSpec && actorSpec.form) {
if (needRebuild) {
for (const child of [...instance.popGroup.children]) {
instance.popGroup.remove(child);
child.traverse((obj) => {
@@ -355,51 +515,55 @@ export const chamber = {
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];
// 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.
const pieces = [];
for (let i = 0; i < count; i++) {
const built = JUNK_BUILDERS[deck[i % deck.length]](T, rng, pal);
built.group.userData.index = i;
pieces.push(built);
}
// 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.
// 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 < 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);
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≈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));
// 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. Deterministic,
// 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.
const minDist = 2.9;
for (let pass = 0; pass < 10; pass++) {
for (let pass = 0; pass < 12; pass++) {
for (let a = 0; a < count; a++) {
for (let b = a + 1; b < count; 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);
@@ -407,72 +571,33 @@ export const chamber = {
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[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(-0.5, seeds[b][1] + nz * push));
seeds[b][1] = Math.max(-8.5, Math.min(-1.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;
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;
instance.popGroup.add(g);
}
instance.builtKey = popKey;
instance.popCount = count;
}
// Analytic animation — bob + slow yaw, no integration
// Furniture stands still — it is junk, not actors. The room's life is
// the camera walk, the story-driven emissive breathe below, and the
// re-hang on arrangement jumps. Positions are never integrated, only
// set at build: a seek lands on the same room as playback.
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) {
// Per-section popMul gates how much of the room is dressed — V1
// keeps the gating the memories had.
{ const popMul = vState ? vState.popMul : 1; const showIndex = idx / Math.max(1, instance.popCount); g.visible = opacity > 0.01 && (showIndex < (0.35 + popMul * 0.75)); }
g.traverse((mesh) => {
if (mesh.isMesh) {
mesh.material.opacity = opacity;
mesh.material.transparent = opacity < 0.999;
@@ -482,7 +607,7 @@ export const chamber = {
mesh.material.emissiveIntensity = 0.5 + vState.light * 1.0;
}
}
}
});
}
// Subtle room drift from camera trait