PROCITY/web/js/citizens/sim.js
m3ultra 9eb1acc61b Lane D (Citizens): deterministic LOD crowd — rigs/impostors/keepers + test page
Populate the town at city scale, on budget, deterministic per-citizen identity:
- rigs.js — ported 90sDJsim rig stack: mixamorig canonicalisation so one shared
  walk clip drives all 19 peds, head-bone height-normalise, feet-plant, pooled
  near-tier actors with walk<->idle crossfade
- impostor.js — 4-yaw sprite-atlas baker + instanced billboards: whole mid crowd
  in 1 draw call, tone-matched to the ACES near rigs
- sim.js — deterministic roster, footpath lanes off the street graph, near/mid/far
  LOD with hysteresis + hard 24-cap, staggered mixer budget, time-of-day density
- placeholder.js — seeded low-poly box humanoids (frame-one population + ?noassets)
- keepers.js — one keeper per shop at the counter, idle + greet-turn
- 21 ped GLBs (byte-identical from 90sDJsim), citizens_test.html harness,
  LANE_D_NOTES.md, 5 beauty shots

Verified in-browser: 200 citizens at max mixer 0.2ms (budget 2ms), near capped 24,
determinism holds (200 identities match seed), no T-pose, ?noassets crash-free.
Consumes Lane A plan.streets with zero adapter. Six adversarial-review bugs fixed
(non-deterministic fleet order, shared-resource disposal on pool eviction,
impostor colour-management).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:43:05 +10:00

408 lines
18 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
// 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.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 }) {
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._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; }
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);
}
// ---- 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);
}
}