Chamber: bring back the actors as wandering figures among the junk
Headcount split (1-3 actors by population, rest junk): actors take the first indices so popMul gating never hides the cast while thinning junk. Each figure is the track's monolith (shared part geometries, own palette material, distinct entries per figure, feet planted via bbox) and walks a closed analytic loop - stand at furniture A facing it, meet at the centre facing its partner (a lone figure watches the window instead), stand at furniture B facing it - on a slow seeded unsynced period. Smootherstep legs rest at both ends, so figures pause, look, then move; facing blends toward the next leg's target for smooth turns. Positions first for all figures, then yaws off same-frame positions; nothing integrates, so seeks match playback. Figures breathe brighter than junk to read against their own room in monochrome palettes. Verified: 24/24 tests, scene lint clean, re-render bit-identical, chamber strips centre 0.09/0.12 (pre-existing flat), drone 0.26->0.57, elegy 0.61->0.59, agit unchanged.
This commit is contained in:
@@ -2,11 +2,13 @@
|
||||
//
|
||||
// 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. 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.
|
||||
// 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 ------------------------------------------------
|
||||
@@ -24,14 +26,15 @@ function mulberry32(a) {
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
// 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] * 0.14, cc[1] * 0.14, cc[2] * 0.14),
|
||||
emissive: new T.Color(cc[0] * emis, cc[1] * emis, cc[2] * emis),
|
||||
emissiveIntensity: 1.0,
|
||||
roughness: rough,
|
||||
metalness: 0.06,
|
||||
@@ -175,7 +178,7 @@ export const chamber = {
|
||||
family: 'minimal',
|
||||
kind: 'model',
|
||||
venue: true,
|
||||
consumes: ['ink', 'staging'],
|
||||
consumes: ['form', 'ink', 'staging'],
|
||||
traits: ['shape', 'space', 'style'],
|
||||
texture: 0.35,
|
||||
// How much viz bloom this room can take before its highlights clip: the
|
||||
@@ -414,7 +417,7 @@ export const chamber = {
|
||||
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 elementScale; void lattice; void shapeSides; void shapeTilt; void styleWeight;
|
||||
|
||||
// Resolve venueSpec — prefer passed-in, fallback to instance stored
|
||||
const vs = venueSpec || instance.venueSpec;
|
||||
@@ -497,7 +500,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 ? `junk:${vs.seed}:${vs.population ? vs.population.count : 0}:a${arrangement}` : `junk:no-vs:${instance.seed}:a${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]) {
|
||||
@@ -515,6 +520,11 @@ 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;
|
||||
// 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
|
||||
@@ -528,11 +538,14 @@ export const chamber = {
|
||||
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.
|
||||
// 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 i = 0; i < count; i++) {
|
||||
const built = JUNK_BUILDERS[deck[i % deck.length]](T, rng, pal);
|
||||
built.group.userData.index = i;
|
||||
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.
|
||||
@@ -543,8 +556,8 @@ export const chamber = {
|
||||
// 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);
|
||||
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;
|
||||
@@ -561,8 +574,8 @@ export const chamber = {
|
||||
// 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 < count; a++) {
|
||||
for (let b = a + 1; b < count; b++) {
|
||||
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];
|
||||
@@ -577,34 +590,163 @@ export const chamber = {
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < count; i++) {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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;
|
||||
// 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)); }
|
||||
const isActor = g.userData.kind === 'actor';
|
||||
// Actors are the subject: always dressed. Junk thins out in
|
||||
// sparse setups via popMul, as before.
|
||||
if (isActor) {
|
||||
g.visible = opacity > 0.01;
|
||||
} else {
|
||||
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;
|
||||
// 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 = 0.5 + vState.light * 1.0;
|
||||
mesh.material.emissiveIntensity = isActor
|
||||
? 0.9 + vState.light * 1.4
|
||||
: 0.5 + vState.light * 1.0;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user