PROCITY/web/js/citizens/rigs.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

203 lines
11 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 rig stack. Ported faithfully from 90sDJsim web/world/index.html ~405500
// (loadRig / spawnRig / _canon / canonRig / _rotOnly / head-bone height-normalize / the shared
// walk+idle clip driving all 19 peds). This stack is proven; the changes for PROCITY are:
// • loaders come from core/loaders.js (promise-cached, fail-soft) instead of an inline one
// • randomness is injected (a seeded stream) — no Math.random() in identity/placement
// • the figure is wrapped in an outer Group whose origin sits at the feet (ground = local y=0),
// so the sim can move/rotate a walker without re-planting it every frame.
//
// House law: one canonical clip bank drives every character (shared skeleton namespace), never a
// per-character rig. SkeletonUtils.clone gives each spawn its own skeleton off a shared base mesh.
import * as THREE from 'three';
import { clone as skeletonClone } from 'three/addons/utils/SkeletonUtils.js';
import { loadGLB } from '../core/loaders.js';
// The 19 rigged peds (17 normal + 2 comical) copied byte-identical from 90sDJsim into web/models/peds/.
export const PED_NAMES = {
normal: ['man_worker_hivis_01', 'man_suit_01', 'man_casual_01', 'man_casual_02', 'man_casual_03',
'man_grunge_01', 'man_elder_01', 'man_athletic_01', 'man_bouncer_01', 'man_soldier_ww2_01',
'man_dj_streetwear_01', 'woman_casual_01', 'woman_casual_02', 'woman_business_01',
'woman_athleisure_01', 'woman_smart_01', 'woman_chef_01'],
comical: ['comical_luchador_01', 'comical_boy_01'],
};
// ---- Mixamo skeleton canonicalisation (the crown-jewel trick) ----
// mixamorig1Hips vs mixamorig4Hips → mixamorig Hips, so ANY clip binds to ANY character.
const _canon = s => s.replace(/mixamorig\d+/g, 'mixamorig');
function canonRig(r) {
if (!r) return r;
if (r.scene) r.scene.traverse(o => { o.name = _canon(o.name); });
if (r.anims) r.anims.forEach(a => a.tracks.forEach(t => { t.name = _canon(t.name); }));
return r;
}
// shared-clip filter: keep limb/spine rotations only — drop ALL position tracks (a different-scale
// source inflates/crumples the target) AND Hips.quaternion (a different-orientation source lays it
// flat). The character keeps its own upright bind root; the game translates walkers itself.
const _rotOnly = c => new THREE.AnimationClip(c.name, c.duration,
c.tracks.filter(t => t.name.endsWith('.quaternion') && !/Hips\.quaternion$/i.test(t.name)));
// core loadGLB returns the whole gltf (scene + animations); adapt to the {scene, anims} rig shape.
function loadRig(ref) {
return loadGLB(ref).then(g => (g ? { scene: g.scene, anims: g.animations || [] } : null));
}
// loadPedFleet(base) → a live Fleet object, returned immediately (ready:false). Arrays fill in as
// GLBs land; fleet.whenReady resolves when every load settled. The town runs on placeholders until
// then, then upgradeStreetPeople swaps them (see sim.js). Missing files just leave the fleet smaller.
export function loadPedFleet(base = 'models/peds/') {
const fleet = {
normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas
walkClip: null, idleClip: null,
ready: false, whenReady: null,
};
// DETERMINISM: fill fixed slots by PED_NAMES index, NOT push-on-resolve — otherwise the array
// order (and thus every pickRig index + identity signature) depends on GLB load-completion timing,
// which varies per run/machine and silently breaks "same seed → same crowd". Compact after all
// settle (filter(Boolean) preserves the fixed relative order, so a missing file is deterministic too).
const nSlots = new Array(PED_NAMES.normal.length).fill(null);
const cSlots = new Array(PED_NAMES.comical.length).fill(null);
const jobs = [];
PED_NAMES.normal.forEach((n, i) => jobs.push(loadRig(`${base}${n}.glb`).then(r => {
if (r) { r.pedName = n; nSlots[i] = canonRig(r); } })));
PED_NAMES.comical.forEach((n, i) => jobs.push(loadRig(`${base}${n}.glb`).then(r => {
if (r) { r.pedName = n; cSlots[i] = canonRig(r); } })));
// shared clips: canonicalise track names, keep rotation only
jobs.push(loadRig(`${base}walk.glb`).then(r => {
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.walkClip = _rotOnly(c); } }));
jobs.push(loadRig(`${base}idle.glb`).then(r => {
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.idleClip = _rotOnly(c); } }));
fleet.whenReady = Promise.all(jobs).then(() => {
fleet.normal = nSlots.filter(Boolean);
fleet.comical = cSlots.filter(Boolean);
fleet.all = [...fleet.normal, ...fleet.comical];
fleet.ready = fleet.all.length > 0 && !!fleet.walkClip;
return fleet;
});
return fleet;
}
// deterministic ped choice: ~8% comical curveball, else a normal, indexed by a seeded stream.
// Returns { rig, index } — index is into fleet.all (stable per ped type → impostor atlas cell).
export function pickRig(fleet, r01) {
const comical = r01 < 0.08 && fleet.comical.length ? fleet.comical : null;
const pool = comical || fleet.normal;
if (!pool.length) return null;
const rig = pool[(r01 * 997 % pool.length) | 0];
const index = fleet.all.indexOf(rig);
return { rig, index: index < 0 ? 0 : index };
}
// ---- buildFigure: clone a rig, height-normalise off the head bone, plant feet ----
// Returns { fig, inner, head }. `fig` is an outer Group whose origin is at the feet (ground = y=0)
// — set fig.position to the ground point and fig.rotation.y to the heading. NOTE materials are
// shared across SkeletonUtils clones (cheap); don't mutate them per-instance.
const _wp = new THREE.Vector3();
const _bb = new THREE.Box3();
const _bs = new THREE.Vector3();
export function buildFigure(rig, height = 1.75) {
const inner = skeletonClone(rig.scene);
inner.traverse(o => { if (o.isMesh) { o.frustumCulled = false; o.castShadow = false; } });
const fig = new THREE.Group();
fig.add(inner);
fig.updateWorldMatrix(true, true);
// height-normalise off the head bone (Mixamo export scale is unreliable), then plant feet.
let headY = 0, minY = Infinity;
inner.traverse(o => {
if (o.isBone) {
o.getWorldPosition(_wp);
if (/head/i.test(o.name)) headY = Math.max(headY, _wp.y);
minY = Math.min(minY, _wp.y);
}
});
if (headY > 1e-4) {
inner.scale.setScalar(height / headY);
} else {
// no head bone found — fall back to bounding-box height so we never ship a giant/ant
_bb.setFromObject(inner); _bb.getSize(_bs);
if (_bs.y > 1e-4) inner.scale.setScalar(height / _bs.y);
}
fig.updateWorldMatrix(true, true);
minY = Infinity;
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); minY = Math.min(minY, _wp.y); } });
if (minY === Infinity) { _bb.setFromObject(inner); minY = _bb.min.y; }
inner.position.y = -minY; // feet at outer y=0
let head = null;
inner.traverse(o => { if (!head && o.isBone && /head/i.test(o.name)) head = o; });
return { fig, inner, head, nominalHeight: height };
}
// bind a clip to an inner figure, keeping only tracks whose target bones exist (else three spams
// "No target node found" per missing bone). Returns the AnimationAction (not yet played).
function _action(mixer, inner, clip) {
if (!clip) return null;
const nodes = new Set();
inner.traverse(o => { if (o.name) nodes.add(o.name); });
const bindable = clip.tracks.filter(t => nodes.has(t.name.split('.')[0]));
const useClip = bindable.length === clip.tracks.length
? clip : new THREE.AnimationClip(clip.name, clip.duration, bindable);
return mixer.clipAction(useClip);
}
// Dispose only what a SkeletonUtils clone actually OWNS: its Skeleton's per-instance bone texture.
// Geometry + materials are SHARED by reference across every clone and the fleet base rig — disposing
// them here would tear down GPU resources still in use by all sibling citizens of the same ped type.
function _disposeInner(inner) {
const skels = new Set();
inner.traverse(o => { if (o.isSkinnedMesh && o.skeleton) skels.add(o.skeleton); });
skels.forEach(s => s.dispose?.());
}
// ---- spawnRig: single-clip figure (keepers, impostor baking). Height baked in. ----
export function spawnRig(rig, { ry = 0, clip = null, height = 1.75, phase = 0 } = {}) {
if (!rig || !rig.scene) return null;
const { fig, inner, head } = buildFigure(rig, height);
fig.rotation.y = ry;
const mixer = new THREE.AnimationMixer(inner);
const chosen = clip || rig.anims.find(c => /idle/i.test(c.name)) || rig.anims[1] || rig.anims[0];
const act = _action(mixer, inner, chosen);
if (act) { act.time = phase * (act.getClip().duration || 0); act.play(); }
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
return { fig, inner, mixer, height, head, dispose };
}
// ---- makeActor: near-tier pedestrian with walk↔idle crossfade (built at nominal height; the sim
// scales the outer Group per-citizen so pooled actors are height-agnostic and reusable). ----
export function makeActor(rig, { walkClip, idleClip, nominalHeight = 1.75 } = {}) {
if (!rig || !rig.scene) return null;
const { fig, inner, head, nominalHeight: nom } = buildFigure(rig, nominalHeight);
const mixer = new THREE.AnimationMixer(inner);
const walkA = _action(mixer, inner, walkClip || rig.anims.find(c => /walk/i.test(c.name)));
const idleA = _action(mixer, inner, idleClip || rig.anims.find(c => /idle/i.test(c.name)));
let moving = null; // tri-state so the first setMoving always applies
// both actions play; exactly one holds weight 1 at rest, setMoving transfers between them
if (walkA) { walkA.play(); walkA.setEffectiveWeight(1); }
if (idleA) { idleA.play(); idleA.setEffectiveWeight(0); }
// walk↔idle: instant on the first apply / fade<=0 (fresh spawn must be posted immediately),
// otherwise a real crossFade transfers weight from the resting action to the target.
function setMoving(m, fade = 0.28) {
if (m === moving) return;
const first = moving === null;
moving = m;
if (!(walkA && idleA)) return;
const to = m ? walkA : idleA, from = m ? idleA : walkA;
to.enabled = from.enabled = true;
if (first || fade <= 0) { to.setEffectiveWeight(1); from.setEffectiveWeight(0); }
else from.crossFadeTo(to, fade, false); // from holds weight 1 at rest → smooth transfer
}
// seed the clip phase so the crowd isn't in lockstep
function setPhase(p) {
if (walkA) walkA.time = p * (walkA.getClip().duration || 1);
if (idleA) idleA.time = p * (idleA.getClip().duration || 1);
}
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
return { fig, inner, mixer, head, nominalHeight: nom, setMoving, setPhase, dispose,
hasClips: !!(walkA && idleA) };
}