PROCITY/web/js/citizens/sim.js
m3ultra 5db3155991 Lane D round-6: harden streamed roster + default-on readiness memo
Flag ?roster=stream stays default-off; v1 path byte-identical (citizen-0 = R4 golden, passes
F1 flags-off regression). Full evidence memo in LANE_D_NOTES for the R7 default-on decision.

- Soak (in-shell, real town): 242 chunk builds/disposes over 3 walks -> 0 GPU geom/tex delta;
  16 shop enter/exit -> 0 delta; heap oscillates 50->77->54MB (GC, no leak); 188 chunk-keyed
  identities re-derive from seed; 0 console errors.
- Hours-aware density: added setNightLivelyChunks + NIGHT_LIVELY_FLOOR so the open-late video
  block stays lively at night (31->16) while ordinary streets go near-empty (65->6). F call-site
  documented (openLate lot -> chunkKey via sim.chunkKeyAt).
- Composition with ?dig=1: roster is street-tier, inert while interior/dig open (frame loop is
  street-branch-only); enter/exit record shops leak-free with stream on. No shared state with any
  v2 flag -> all-on combo safe.
- Budget gap found + made safe: each decimated ped is 8 sub-meshes / 2 materials = ~7 draws/rig,
  so full-density stream (perChunk 16) hits 356 draws (>300 street budget). Set default perChunk 8
  (291 draws, budget-safe today). The clean unlock for full-density default-on = merge ped
  sub-meshes by material (8->2 -> ~2 draws/rig -> perChunk 16 fits ~260); documented as R7 work,
  F1-safe (v1 spawn view has ~0 near-rigs).

Only web/js/citizens/sim.js + web/citizens_test.html changed (+ NOTES/progress).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:56:39 +10:00

583 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 (2570m): 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
const NIGHT_LIVELY_FLOOR = 0.5; // stream: "lively" chunks (open-late block) keep ≥this density at night
// 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 };
// ---- chunk math (v2 chunk-streamed roster). CHUNK=64m is CITY_SPEC law and matches Lane B's
// planutil.js exactly, so B's onChunkBuilt/onChunkDisposed keys line up with ours. Kept local so the
// sim stays dependency-free (the standalone test page has no Lane B modules). ----
const CHUNK = 64;
const chunkCoord = (v) => Math.floor(v / CHUNK);
const chunkKey = (cx, cz) => `${cx},${cz}`;
// ---- 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.551.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, chunkStream = 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 = []; // v1: all generated citizens (identity + live state), indexed by id
this.target = 0; // slider population
// v2 chunk-streamed roster (behind ?roster=stream — see enableStream). Default OFF → v1 path.
this.streamMode = false;
this.chunkRosters = new Map(); // chunkKey → Citizen[] (owned by that chunk)
this.chunkEdges = null; // chunkKey → [edge index] (built lazily on enable)
this._hookDriven = false; // Lane B's onChunkBuilt/Disposed drive us → stop polling
this._livelyChunks = new Set(); // chunkKeys that resist night thinning (e.g. the open-late block)
this._encountered = new Set(); // cumulative identity signatures seen (determinism proof)
this._dropKeys = []; // scratch: chunks to drop this frame
this._activeList = []; // this frame's active citizens (both modes) — test page reads it
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(); });
if (chunkStream) this.enableStream(chunkStream === true ? {} : chunkStream);
}
// ================= v2: chunk-streamed roster (behind ?roster=stream) =================
// Constant per-unit-street density that follows the camera, so streets stay lively arbitrarily far
// from spawn (v1's fixed roster smears N over the whole town and thins out). Identity is keyed per
// chunk (same seed + chunk → same residents, independent of town size / visit order). Everything
// downstream — LOD tiers, the 24 near-cap, mixer stagger, rig pool, impostor layer — is already
// per-citizen and chunk-agnostic and runs GLOBAL across live chunks, unchanged.
// perChunk default 8 keeps the worst continuous-walk street view ≤300 draws in a real town TODAY.
// Ceiling cause: each decimated ped is 8 sub-meshes but only 2 materials → ~7 draws/near-rig, so a
// dense street of near-rigs blows the 300 budget. The clean unlock (LANE_D_NOTES R6 memo) is merging
// each ped's sub-meshes by material (8→2) — then perChunk can go back to ~16 with headroom.
enableStream({ radius = 2, perChunk = 8 } = {}) {
if (this.streamMode) return;
this.roster.forEach(c => this._releaseActor(c)); this.roster.length = 0; // tear down the v1 roster
if (!this.chunkEdges) this._buildChunkIndex();
this.chunkRosters.clear(); this._dropKeys.length = 0; this._encountered.clear();
this.streamRadius = radius; this.streamPerChunk = perChunk;
this.streamMode = true; this._hookDriven = false;
}
disableStream() {
if (!this.streamMode) return;
for (const arr of this.chunkRosters.values()) arr.forEach(c => this._releaseActor(c));
this.chunkRosters.clear(); this.streamMode = false;
}
// rasterise each edge's centreline to the 64m chunks it passes through → which edges seed each chunk
_buildChunkIndex() {
this.chunkEdges = new Map();
this.edges.forEach((e, ei) => {
const steps = Math.max(1, Math.ceil(e.len / (CHUNK / 2)));
const seen = new Set();
for (let i = 0; i <= steps; i++) {
const t = i / steps, x = e.A.x + (e.B.x - e.A.x) * t, z = e.A.z + (e.B.z - e.A.z) * t;
const k = chunkKey(chunkCoord(x), chunkCoord(z));
if (!seen.has(k)) {
seen.add(k);
let arr = this.chunkEdges.get(k); if (!arr) this.chunkEdges.set(k, arr = []);
arr.push(ei);
}
}
});
}
// Lane B seam (F wires these onto ctx.onChunkBuilt/onChunkDisposed). First call flips us hook-driven.
onChunkBuilt(key) { if (this.streamMode) { this._hookDriven = true; this.feedChunk(key); } }
onChunkDisposed(key) { if (this.streamMode) { this._hookDriven = true; this.dropChunk(key); } }
// poll-driven window (works with zero Lane B changes — test page + shell-until-F-wires-hooks):
// the camera chunk + neighbours within radius R (R≥1 so a walker never enters an unloaded chunk).
_pollChunks() {
const cx = chunkCoord(this.camera.position.x), cz = chunkCoord(this.camera.position.z);
const R = this.streamRadius;
const want = new Set();
for (let dx = -R; dx <= R; dx++) for (let dz = -R; dz <= R; dz++) {
const k = chunkKey(cx + dx, cz + dz);
if (this.chunkEdges.has(k)) want.add(k);
}
for (const k of want) if (!this.chunkRosters.has(k)) this.feedChunk(k);
this._dropKeys.length = 0;
for (const k of this.chunkRosters.keys()) if (!want.has(k)) this._dropKeys.push(k);
for (const k of this._dropKeys) this.dropChunk(k);
}
feedChunk(key) {
if (this.chunkRosters.has(key)) return;
const edgeList = this.chunkEdges.get(key);
if (!edgeList || !edgeList.length) { this.chunkRosters.set(key, []); return; }
const n = this._perChunkMax(key);
const arr = new Array(n);
for (let i = 0; i < n; i++) { const c = this._makeChunkCitizen(key, i, edgeList); arr[i] = c; this._encountered.add(this._sig(c)); }
this.chunkRosters.set(key, arr);
}
dropChunk(key) {
const arr = this.chunkRosters.get(key);
if (!arr) return;
for (const c of arr) this._releaseActor(c);
this.chunkRosters.delete(key);
}
// per-chunk population: base × district weight (more edges = busier junction) × seeded jitter.
_perChunkMax(key) {
const edges = this.chunkEdges.get(key) || [];
const r = rng(this.citySeed, 'chunkpop', key);
const busy = 0.7 + Math.min(1.3, edges.length * 0.12);
return Math.max(4, Math.round(this.streamPerChunk * busy * (0.75 + r() * 0.5)));
}
// chunk-local identity: home edge is picked from THIS chunk's edges, so town size / visit order
// never perturb who lives here. id is the string `${chunkKey}#${i}` (prng streams accept strings).
_makeChunkCitizen(key, i, edgeList) {
const id = `${key}#${i}`;
const idn = identityOf(this.citySeed, edgeList.length, id);
const edge = edgeList[idn.edge] ?? edgeList[0];
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: edge, forward0: idn.forward, localEdge0: idn.edge, ownerChunk: key,
edge, forward: idn.forward, s: idn.sFrac * this.edges[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),
};
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;
}
_sig(c) {
return signatureOf(c.id, { pvar: c.pvar, height: c.height, speed: c.speed, edge: c.localEdge0 ?? c.edge0, forward: c.forward0 }, c.pedIndex);
}
activeCitizens() { return this._activeList; }
streamEncountered() { return [...this._encountered].sort(); }
// hours-aware: mark chunk keys that stay lively at night (the open-late block). The shell computes
// these from the plan (openLate shop's lot → chunkKey, + neighbours) and passes them — the sim is
// graph-only so it can't derive them itself. Empty = every chunk thins uniformly by the day curve.
setNightLivelyChunks(keys) { this._livelyChunks = new Set(keys); }
chunkKeyAt(x, z) { return chunkKey(chunkCoord(x), chunkCoord(z)); }
// ---- 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). Both modes.
identitySignature() {
return this._activeList.map(c => this._sig(c));
}
// ---- 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
const assign = 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); }
};
this.roster.forEach(assign);
for (const arr of this.chunkRosters.values()) arr.forEach(assign); // stream-mode rosters too
// encountered signatures now carry pedIndex → refresh so the determinism proof matches post-upgrade
if (this.streamMode) { this._encountered.clear(); for (const arr of this.chunkRosters.values()) arr.forEach(c => this._encountered.add(this._sig(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 set of citizens active this frame — v1: the roster prefix scaled by time-of-day; stream: the
// union of live-chunk residents, each chunk thinned by the same time-of-day curve (hours-aware
// density per unit street). Deactivates everyone else. Downstream code is identical for both.
_activeSet() {
const list = [];
const density = densityAt(this.timeOfDay);
if (this.streamMode) {
const haveLively = this._livelyChunks.size > 0;
for (const [key, arr] of this.chunkRosters) {
// hours-aware: every chunk thins by the day curve, but "lively" chunks (the open-late block)
// keep a night floor so that street never goes dead while its shop is still open.
const d = (haveLively && this._livelyChunks.has(key)) ? Math.max(density, NIGHT_LIVELY_FLOOR) : density;
const activeCount = Math.round(arr.length * d);
for (let i = 0; i < arr.length; i++) {
if (i < activeCount) list.push(arr[i]);
else if (arr[i].tier !== 'far') { this._releaseActor(arr[i]); arr[i].tier = 'far'; }
}
}
} else {
const active = Math.min(this.roster.length, Math.round(this.target * density));
this._ensureRoster(active);
for (let i = active; i < this.roster.length; i++) {
const c = this.roster[i];
if (c.tier !== 'far') { this._releaseActor(c); c.tier = 'far'; }
}
for (let i = 0; i < active; i++) list.push(this.roster[i]);
}
return list;
}
// ---- 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
if (this.streamMode && !this._hookDriven) this._pollChunks(); // camera-follow window (or Lane B hooks)
const activeCitizens = this._activeSet();
this._activeList = activeCitizens;
// 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 (const c of activeCitizens) {
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)
const active = activeCitizens.length;
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,
chunks: this.streamMode ? this.chunkRosters.size : 0,
};
return this.stats;
}
dispose() {
this.roster.forEach(c => this._releaseActor(c));
for (const arr of this.chunkRosters.values()) arr.forEach(c => this._releaseActor(c));
this.chunkRosters.clear();
if (this.rigPool) this.rigPool.dispose();
if (this.impostor) { this.group.remove(this.impostor.mesh); this.impostor.dispose(); }
this.scene.remove(this.group);
}
}