Fable round-3 §Lane D (all three tasks): - sim.js: add CitizenSim.setExposure(e) passthrough so mid-tier billboards track the shell's per-segment renderer.toneMappingExposure (lighting.js animates it); stored so it survives the placeholder->rig atlas re-bake. Verified: uniform tracks 0.55<->2.3 and persists across the fleet upgrade. - Keeper GLB-rig upgrade contract verified leak-free (my side): 15 enter/exit cycles with a fleet-backed rig keeper leave renderer.info.memory geo/tex exactly at baseline. Enabling it in the shell is one arg (pass `fleet` to KeeperManager) once F's §3.4 lands. - LANE_D_NOTES: exact Lane F call sites for setExposure + keeper fleet-upgrade, and the chunk-streamed roster v1.5 design note (chunk-local identity, windowing, ownership/hand-off, budget, opt-in migration) — design only, no implementation. Code touches only sim.js; no sibling files edited. Determinism still passes, near cap 24, mixer 0.3ms, no console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
417 lines
19 KiB
JavaScript
417 lines
19 KiB
JavaScript
// PROCITY Lane D — the citizen simulation (Vuntra's layered LOD idea, at hamlet scale).
|
||
//
|
||
// Deterministic per-citizen identity (same seed → same person walks the same beat), walkers pathing
|
||
// along footpath lanes offset from the street graph, three distance tiers:
|
||
// • NEAR (<25m): full rig + AnimationMixer (or a placeholder before the fleet loads), walking the
|
||
// footpath, turning at nodes on a seeded choice, loitering at points of interest.
|
||
// • MID (25–70m): no mixers ticking — an instanced billboard impostor (impostor.js).
|
||
// • FAR (>70m): culled entirely.
|
||
// Promotion/demotion has hysteresis + a hard near-cap so nobody pops and the frame budget holds.
|
||
//
|
||
// Built on the house fleet rule: shared base meshes (a small rig pool, reused across citizens of the
|
||
// same ped type) + one canonical clip bank. Never a per-character rig.
|
||
|
||
import * as THREE from 'three';
|
||
import { rng, seedFor } from '../core/prng.js';
|
||
import { pickRig, makeActor } from './rigs.js';
|
||
import { makePlaceholder } from './placeholder.js';
|
||
import { bakeImpostorAtlas, ImpostorLayer } from './impostor.js';
|
||
|
||
// ---- tuning (CITY_SPEC budgets) ----
|
||
const NEAR_ENTER = 24, NEAR_EXIT = 27; // m, hysteresis band for rig↔impostor
|
||
const MID_ENTER = 68, MID_EXIT = 72; // m, hysteresis band for impostor↔culled
|
||
const NEAR_MAX = 24; // ≤24 rigged actives (CITY_SPEC)
|
||
const RIG_CAP = 30; // pooled rig instances (headroom above NEAR_MAX)
|
||
const NEW_RIG_PER_FRAME = 3; // cap actor creation to avoid clone hitches
|
||
const MIXER_ALWAYS = 8; // nearest 8 mixers update every frame
|
||
const MIXER_EXTRA = 4; // ≤4 more mixers per frame (staggered), CITY_SPEC
|
||
const NEAR_BIAS = 2.0; // currently-near citizens keep priority for a rig slot
|
||
const PLACEHOLDER_VARIANTS = 8; // impostor atlas subjects in asset-free mode
|
||
const FOOTPATH_MARGIN = 0.9; // m outside the carriageway edge
|
||
const IMPOSTOR_MAX = 220; // instanced billboards ceiling
|
||
|
||
// time-of-day density curve: t01 in [0,1) over a day → crowd multiplier (CITY_SPEC: lunch rush,
|
||
// near-empty at night). Sampled at 8 control points, linearly interpolated.
|
||
const DAY_CURVE = [0.06, 0.10, 0.35, 0.85, 1.0, 0.75, 0.45, 0.18]; // 00,03,06,09,12,15,18,21h
|
||
function densityAt(t01) {
|
||
const x = ((t01 % 1) + 1) % 1 * DAY_CURVE.length;
|
||
const i = Math.floor(x), f = x - i;
|
||
return DAY_CURVE[i % DAY_CURVE.length] * (1 - f) + DAY_CURVE[(i + 1) % DAY_CURVE.length] * f;
|
||
}
|
||
|
||
const tierColor = { near: 0x66dd88, mid: 0xffcc44, far: 0x884466 };
|
||
|
||
// ---- pure deterministic identity (no THREE, no GPU) — same (seed, id) → same person, forever ----
|
||
// Kept free-standing so the determinism check can recompute it independently of the live sim.
|
||
export function identityOf(citySeed, edgeCount, id) {
|
||
const r = rng(citySeed, 'citizen', id);
|
||
const pedRoll = r();
|
||
const height = 1.55 + r() * 0.40; // 1.55–1.95 m (CITY_SPEC height range)
|
||
const speed = 1.05 + r() * 0.75; // m/s
|
||
const edge = (r() * edgeCount) | 0;
|
||
const forward = r() < 0.5 ? 1 : -1;
|
||
const sFrac = r(); // 0..1 along the edge
|
||
const loiterTend = r();
|
||
const phase = r();
|
||
const pvar = (pedRoll * PLACEHOLDER_VARIANTS) | 0;
|
||
return { pedRoll, height, speed, edge, forward, sFrac, loiterTend, phase, pvar };
|
||
}
|
||
// stable signature string for one citizen (pedIndex assigned later, once the fleet is known)
|
||
export function signatureOf(id, idn, pedIndex) {
|
||
return `${id}:${pedIndex}:${idn.pvar}:${idn.height.toFixed(3)}:${idn.speed.toFixed(3)}:${idn.edge}:${idn.forward}`;
|
||
}
|
||
|
||
// ---- a small pool of rig actors, reused across citizens of the same ped type ----
|
||
class RigPool {
|
||
constructor(fleet, clips) { this.fleet = fleet; this.clips = clips; this.free = new Map(); this.total = 0; }
|
||
acquire(pedIndex) {
|
||
const list = this.free.get(pedIndex);
|
||
if (list && list.length) return list.pop();
|
||
if (this.total >= RIG_CAP) { // evict one free actor of another type
|
||
for (const [k, arr] of this.free) { if (arr.length) { arr.pop().dispose(); this.total--; break; } }
|
||
if (this.total >= RIG_CAP) return null; // everything in use — caller falls back to impostor
|
||
}
|
||
const rig = this.fleet.all[pedIndex];
|
||
if (!rig) return null;
|
||
const actor = makeActor(rig, this.clips);
|
||
if (actor) { actor.pedIndex = pedIndex; this.total++; }
|
||
return actor;
|
||
}
|
||
release(actor) {
|
||
if (!actor) return;
|
||
let arr = this.free.get(actor.pedIndex);
|
||
if (!arr) this.free.set(actor.pedIndex, arr = []);
|
||
arr.push(actor);
|
||
}
|
||
dispose() { for (const arr of this.free.values()) arr.forEach(a => a.dispose()); this.free.clear(); this.total = 0; }
|
||
}
|
||
|
||
export class CitizenSim {
|
||
constructor({ renderer, scene, camera, citySeed = 20261990, graph, fleet, group = null }) {
|
||
this.renderer = renderer;
|
||
this.scene = scene;
|
||
this.camera = camera;
|
||
this.citySeed = citySeed >>> 0;
|
||
this.fleet = fleet;
|
||
this.group = group || new THREE.Group();
|
||
this.group.name = 'citizens';
|
||
scene.add(this.group);
|
||
|
||
this._setGraph(graph);
|
||
|
||
this.roster = []; // all generated citizens (identity + live state), indexed by id
|
||
this.target = 0; // slider population
|
||
this.timeOfDay = 0.5; // noon
|
||
this.debugTiers = false;
|
||
this.mode = 'placeholder'; // 'placeholder' until the fleet is ready, then 'rig'
|
||
this.rigPool = null;
|
||
this.paused = false; // app sets this on a visibilitychange → hidden (pauses mixers)
|
||
this._exposure = null; // shell's live renderer.toneMappingExposure (day/night) → impostor match
|
||
this._mixerCursor = 0; // round-robin cursor for the staggered mixer budget
|
||
this._nearList = []; // reused scratch
|
||
this._midList = [];
|
||
this._frustum = new THREE.Frustum();
|
||
this._pv = new THREE.Matrix4();
|
||
this.stats = { active: 0, rigged: 0, mid: 0, far: 0, mixerMs: 0, poolTotal: 0, mode: this.mode };
|
||
|
||
// bake the placeholder impostor atlas up front so the mid tier works from frame one
|
||
this._buildPlaceholderImpostors();
|
||
|
||
// when the fleet lands, upgrade: bake rig atlas, swap actors, assign real ped types
|
||
if (fleet && fleet.whenReady) fleet.whenReady.then(() => { if (fleet.ready) this._upgradeToRigs(); });
|
||
}
|
||
|
||
// ---- graph → footpath lanes ----
|
||
_setGraph(graph) {
|
||
this.nodes = graph.nodes;
|
||
this.edges = graph.edges.map(e => {
|
||
const A = graph.nodes.find(n => n.id === e.a), B = graph.nodes.find(n => n.id === e.b);
|
||
const dx = B.x - A.x, dz = B.z - A.z, len = Math.hypot(dx, dz) || 1e-3;
|
||
const ux = dx / len, uz = dz / len;
|
||
const off = (e.width || 4) * 0.5 + FOOTPATH_MARGIN;
|
||
return { ...e, A, B, ux, uz, len, off };
|
||
});
|
||
// adjacency: node id → incident edge indices
|
||
this.adj = new Map();
|
||
this.nodes.forEach(n => this.adj.set(n.id, []));
|
||
this.edges.forEach((e, i) => { this.adj.get(e.a).push(i); this.adj.get(e.b).push(i); });
|
||
}
|
||
|
||
// ---- deterministic identity ----
|
||
_makeCitizen(id) {
|
||
const idn = identityOf(this.citySeed, this.edges.length, id);
|
||
const c = {
|
||
id, pedRoll: idn.pedRoll, height: idn.height, speed: idn.speed,
|
||
loiterTend: idn.loiterTend, phase: idn.phase, pvar: idn.pvar,
|
||
pedIndex: -1, subject: idn.pvar,
|
||
edge0: idn.edge, forward0: idn.forward, // immutable spawn beat (for the determinism signature)
|
||
// live state (mutates as they walk + turn at nodes)
|
||
edge: idn.edge, forward: idn.forward, s: idn.sFrac * this.edges[idn.edge].len, loiter: 0,
|
||
x: 0, z: 0, facing: 0,
|
||
tier: 'far', actor: null, actorKind: null,
|
||
_acc: 0,
|
||
turn: rng(this.citySeed, 'turn', id),
|
||
loit: rng(this.citySeed, 'loiter', id),
|
||
};
|
||
// assign a real ped type if the fleet is already up (roster can grow after upgrade)
|
||
if (this.mode === 'rig' && this.fleet.ready) {
|
||
const pk = pickRig(this.fleet, c.pedRoll);
|
||
if (pk) { c.pedIndex = pk.index; c.subject = pk.index; }
|
||
}
|
||
this._placeOnLane(c);
|
||
return c;
|
||
}
|
||
|
||
_ensureRoster(n) {
|
||
while (this.roster.length < n) this.roster.push(this._makeCitizen(this.roster.length));
|
||
}
|
||
|
||
// ---- public controls ----
|
||
setPopulation(n) { this.target = Math.max(0, n | 0); this._ensureRoster(this.target); }
|
||
setTimeOfDay(t01) { this.timeOfDay = t01; }
|
||
// Match mid-tier billboards to the shell's day/night exposure. The impostor atlas is baked at a
|
||
// fixed exposure and self-tone-maps (toneMapped:false), so as the shell animates
|
||
// renderer.toneMappingExposure across day segments the billboards would drift brighter/darker than
|
||
// the near rigs. Call this each frame with the live exposure. Stored so an atlas re-bake (the
|
||
// placeholder→rig upgrade) inherits it. (Exposure only — the atlas can't track per-segment sun/hemi
|
||
// colour; that would need a re-bake. Close enough for the LOD swap.)
|
||
setExposure(e) { this._exposure = e; if (this.impostor) this.impostor.setExposure(e); }
|
||
setDebugTiers(on) {
|
||
this.debugTiers = !!on;
|
||
if (this.impostor) this.impostor.setTint(on ? tierColor.mid : 0xffffff);
|
||
}
|
||
// stable identity signature of the active set — immutable spawn identity, NOT live position, so it
|
||
// holds while citizens walk (the determinism gate: same seed → same crowd, twice).
|
||
identitySignature() {
|
||
return this.roster.slice(0, this.stats.active).map(c =>
|
||
signatureOf(c.id, { pvar: c.pvar, height: c.height, speed: c.speed, edge: c.edge0, forward: c.forward0 }, c.pedIndex)
|
||
);
|
||
}
|
||
|
||
// ---- impostor atlases ----
|
||
_buildPlaceholderImpostors() {
|
||
const subjects = [];
|
||
for (let v = 0; v < PLACEHOLDER_VARIANTS; v++) {
|
||
const ph = makePlaceholder(rng(this.citySeed, 'pvar', v), { height: 1.9 });
|
||
subjects.push({ object3D: ph.fig, height: 1.9 });
|
||
}
|
||
const atlas = bakeImpostorAtlas(this.renderer, subjects,
|
||
{ yaws: 4, cell: 128, environment: this.scene.environment });
|
||
subjects.forEach(s => s.object3D.traverse(o => { if (o.isMesh) o.material.dispose?.(); }));
|
||
this._setImpostorLayer(atlas);
|
||
}
|
||
|
||
_upgradeToRigs() {
|
||
// bake one atlas cell-set per ped type, from a representative mid-stride pose
|
||
const temps = [], subjects = [];
|
||
for (let i = 0; i < this.fleet.all.length; i++) {
|
||
const rig = this.fleet.all[i];
|
||
const spawned = makeActor(rig, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip, nominalHeight: 1.9 });
|
||
if (!spawned) continue;
|
||
spawned.setMoving(true, 0);
|
||
spawned.mixer.update(0.35 + i * 0.017); // desynced mid-stride so the atlas isn't all one pose
|
||
temps.push(spawned);
|
||
subjects.push({ object3D: spawned.fig, height: 1.9, pedIndex: i });
|
||
}
|
||
if (!subjects.length) return;
|
||
const atlas = bakeImpostorAtlas(this.renderer, subjects,
|
||
{ yaws: 4, cell: 128, environment: this.scene.environment });
|
||
temps.forEach(t => t.dispose());
|
||
this._setImpostorLayer(atlas);
|
||
|
||
this.mode = 'rig';
|
||
this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip });
|
||
// assign real ped types to every citizen; drop placeholder near-actors so they re-acquire as rigs
|
||
this.roster.forEach(c => {
|
||
const pk = pickRig(this.fleet, c.pedRoll);
|
||
if (pk) { c.pedIndex = pk.index; c.subject = pk.index; }
|
||
if (c.actor && c.actorKind === 'placeholder') { this._releaseActor(c); }
|
||
});
|
||
}
|
||
|
||
_setImpostorLayer(atlas) {
|
||
if (this.impostor) { this.group.remove(this.impostor.mesh); this.impostor.dispose(); }
|
||
this.impostor = new ImpostorLayer(atlas, { maxInstances: IMPOSTOR_MAX });
|
||
this.group.add(this.impostor.mesh);
|
||
if (this.debugTiers) this.impostor.setTint(tierColor.mid);
|
||
if (this._exposure != null) this.impostor.setExposure(this._exposure); // survive the upgrade re-bake
|
||
}
|
||
|
||
// ---- lane math ----
|
||
_placeOnLane(c) {
|
||
const e = this.edges[c.edge];
|
||
const start = c.forward > 0 ? e.A : e.B;
|
||
const tdx = c.forward * e.ux, tdz = c.forward * e.uz; // travel direction
|
||
// right-perpendicular (dz,-dx): opposing walkers take opposite footpaths
|
||
const px = tdz, pz = -tdx;
|
||
c.x = start.x + tdx * c.s + px * e.off;
|
||
c.z = start.z + tdz * c.s + pz * e.off;
|
||
c.facing = Math.atan2(-tdx, -tdz); // rig front = local -Z
|
||
}
|
||
|
||
_advance(c, dt) {
|
||
if (c.loiter > 0) { c.loiter -= dt; return; }
|
||
const e = this.edges[c.edge];
|
||
c.s += c.speed * dt;
|
||
if (c.s >= e.len) {
|
||
// arrived at the far node — pick the next edge (seeded), maybe loiter
|
||
const node = c.forward > 0 ? e.b : e.a;
|
||
const inc = this.adj.get(node);
|
||
let choices = inc.filter(i => i !== c.edge);
|
||
if (!choices.length) choices = inc; // dead-end → U-turn
|
||
const next = choices[(c.turn() * choices.length) | 0];
|
||
const ne = this.edges[next];
|
||
c.edge = next;
|
||
c.forward = ne.a === node ? 1 : -1;
|
||
c.s = Math.min(c.s - e.len, ne.len); // carry leftover distance
|
||
if (c.loit() < 0.10 + c.loiterTend * 0.28) c.loiter = 1.4 + c.loit() * 3.2; // window-shop stop
|
||
}
|
||
this._placeOnLane(c);
|
||
}
|
||
|
||
// ---- actor lifecycle ----
|
||
_acquireActor(c) {
|
||
if (this.mode === 'rig' && this.rigPool && c.pedIndex >= 0) {
|
||
const a = this.rigPool.acquire(c.pedIndex);
|
||
if (a) {
|
||
a.setPhase(c.phase);
|
||
a.setMoving(c.loiter <= 0, 0); // instant, no fade, so the first frame is posed
|
||
a.mixer.update(0); // evaluate NOW — a fresh clone must never show bind-pose (T-pose)
|
||
a.fig.scale.setScalar(c.height / (a.nominalHeight || 1.75));
|
||
this.group.add(a.fig);
|
||
c.actor = a; c.actorKind = 'rig'; return true;
|
||
}
|
||
return false; // pool exhausted this frame → stay an impostor
|
||
}
|
||
// placeholder mode (asset-free) — build a unique-coloured figure
|
||
const ph = makePlaceholder(rng(this.citySeed, 'body', c.id), { height: c.height });
|
||
this.group.add(ph.fig);
|
||
c.actor = ph; c.actorKind = 'placeholder'; return true;
|
||
}
|
||
|
||
_releaseActor(c) {
|
||
if (!c.actor) return;
|
||
this.group.remove(c.actor.fig);
|
||
if (c.actorKind === 'rig' && this.rigPool) this.rigPool.release(c.actor);
|
||
else c.actor.dispose?.();
|
||
c.actor = null; c.actorKind = null; c._acc = 0;
|
||
}
|
||
|
||
setPaused(p) { this.paused = !!p; }
|
||
|
||
// ---- the frame ----
|
||
update(dt) {
|
||
if (this.paused) return this.stats; // tab hidden → mixers frozen (app drives this via events)
|
||
dt = Math.min(dt, 0.1); // clamp long frames (tab refocus) so nobody teleports
|
||
|
||
// active population from slider × time-of-day
|
||
const active = Math.min(this.roster.length, Math.round(this.target * densityAt(this.timeOfDay)));
|
||
this._ensureRoster(active);
|
||
|
||
// deactivate anyone past the active prefix
|
||
for (let i = active; i < this.roster.length; i++) {
|
||
const c = this.roster[i];
|
||
if (c.tier !== 'far') { this._releaseActor(c); c.tier = 'far'; }
|
||
}
|
||
|
||
// advance + measure distances for the active set
|
||
const cam = this.camera;
|
||
const camX = cam.position.x, camZ = cam.position.z;
|
||
const near = this._nearList; near.length = 0;
|
||
const cand = [];
|
||
for (let i = 0; i < active; i++) {
|
||
const c = this.roster[i];
|
||
this._advance(c, dt);
|
||
const ddx = c.x - camX, ddz = c.z - camZ;
|
||
c._d = Math.hypot(ddx, ddz);
|
||
cand.push(c);
|
||
}
|
||
|
||
// choose the near set: hysteresis eligibility, then nearest-first up to the cap
|
||
const eligible = [];
|
||
for (const c of cand) {
|
||
const wasNear = c.tier === 'near';
|
||
if (c._d < (wasNear ? NEAR_EXIT : NEAR_ENTER)) eligible.push(c);
|
||
}
|
||
eligible.sort((a, b) => (a._d - (a.tier === 'near' ? NEAR_BIAS : 0)) - (b._d - (b.tier === 'near' ? NEAR_BIAS : 0)));
|
||
const nearSet = new Set(eligible.slice(0, NEAR_MAX));
|
||
|
||
// assign tiers + representations
|
||
const mid = this._midList; mid.length = 0;
|
||
let newRigs = 0;
|
||
for (const c of cand) {
|
||
let want;
|
||
if (nearSet.has(c)) want = 'near';
|
||
else if (c._d < (c.tier === 'far' ? MID_ENTER : MID_EXIT)) want = 'mid';
|
||
else want = 'far';
|
||
|
||
if (want === 'near') {
|
||
// acquire an actor (budgeted); if it fails this frame, ride as an impostor instead
|
||
if (!c.actor) {
|
||
if (newRigs < NEW_RIG_PER_FRAME && this._acquireActor(c)) newRigs++;
|
||
else want = 'mid';
|
||
} else if (c.actorKind === 'placeholder' && this.mode === 'rig') {
|
||
this._releaseActor(c); // upgrade path: swap placeholder → rig
|
||
if (newRigs < NEW_RIG_PER_FRAME && this._acquireActor(c)) newRigs++; else want = 'mid';
|
||
}
|
||
}
|
||
if (want !== 'near' && c.actor) this._releaseActor(c);
|
||
|
||
if (want === 'near' && c.actor) {
|
||
near.push(c);
|
||
const a = c.actor;
|
||
a.fig.position.set(c.x, 0, c.z);
|
||
a.fig.rotation.y = c.facing;
|
||
a.setMoving?.(c.loiter <= 0);
|
||
} else if (want === 'mid') {
|
||
mid.push(c);
|
||
}
|
||
c.tier = want;
|
||
}
|
||
|
||
// near animation: rigs on a staggered mixer budget; placeholders tick cheaply every frame
|
||
near.sort((a, b) => a._d - b._d);
|
||
const t0 = (typeof performance !== 'undefined' ? performance.now() : 0);
|
||
const extra = [];
|
||
for (let i = 0; i < near.length; i++) {
|
||
const c = near[i], a = c.actor;
|
||
c._acc += dt;
|
||
if (a.mixer) {
|
||
if (i < MIXER_ALWAYS) { a.mixer.update(c._acc); c._acc = 0; } else extra.push(c);
|
||
} else {
|
||
a.tick?.(c._acc, c.loiter <= 0); c._acc = 0; // placeholder
|
||
}
|
||
}
|
||
// round-robin the mixers beyond the nearest 8, ≤MIXER_EXTRA per frame
|
||
if (extra.length) {
|
||
if (this._mixerCursor >= extra.length) this._mixerCursor = 0;
|
||
for (let k = 0; k < MIXER_EXTRA && k < extra.length; k++) {
|
||
const c = extra[(this._mixerCursor + k) % extra.length];
|
||
c.actor.mixer.update(c._acc); c._acc = 0;
|
||
}
|
||
this._mixerCursor = (this._mixerCursor + MIXER_EXTRA) % extra.length;
|
||
}
|
||
const mixerMs = (typeof performance !== 'undefined' ? performance.now() : 0) - t0;
|
||
|
||
// mid tier: one instanced draw call
|
||
if (this.impostor) {
|
||
const list = mid.length > IMPOSTOR_MAX ? mid.slice(0, IMPOSTOR_MAX) : mid;
|
||
this.impostor.update(list, cam);
|
||
}
|
||
|
||
// debug tier tint on near actors (rig materials are shared → tint the whole group cheaply instead)
|
||
this.stats = {
|
||
active, rigged: near.length, mid: mid.length, far: active - near.length - mid.length,
|
||
mixerMs, poolTotal: this.rigPool ? this.rigPool.total : 0, mode: this.mode,
|
||
};
|
||
return this.stats;
|
||
}
|
||
|
||
dispose() {
|
||
this.roster.forEach(c => this._releaseActor(c));
|
||
if (this.rigPool) this.rigPool.dispose();
|
||
if (this.impostor) { this.group.remove(this.impostor.mesh); this.impostor.dispose(); }
|
||
this.scene.remove(this.group);
|
||
}
|
||
}
|