PROCITY/web/js/citizens/rigs.js
m3ultra b3394027be feat(citizens): +5 trellis DJ bodies in the ped roster
Same 5 Mixamo-rigged DJ characters as 90sDJsim, converted FBX->skinned GLB +
decimated to 4k tris (<=5k ped law) via MESHGOD scripts/fbx_to_ped.py — mixamorig:
skeleton + skin preserved. Added to PED_NAMES (rigs.js). Shares the proven bind:
identical _canon/_rotOnly + the same GLBs verified animating in 90sDJsim.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 01:37:10 +10:00

347 lines
21 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',
'woman_dj_01', 'woman_dj_02', 'woman_dj_03', 'dj_techno_01', 'dj_phrtt_01'], // trellis'd DJ bodies (fbx_to_ped, 4k tris)
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)));
// R16 sit clip note: the sit pose ALSO rides `_rotOnly`. R16 first tried keeping `Hips.quaternion` (a
// `_rotWithHips` variant, per the R14 recon) to preserve the pelvic tilt — but the fleet rigs' bind-pose
// Hips orientation differs from the Mixamo source, so keeping the sit Hips.quaternion LAYS THE BODY FLAT
// (head folded to hip height — verified in-shell), exactly the failure the comment above documents for
// walk/idle. So the sit clip drops Hips.quaternion like every other clip; the seated read comes from the
// leg-bone bends + spawnRig's seated foot-replant. Reads seated from the crowd POV (the kit hides the legs).
// 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.
// `opts.sit` (R16): also load sit.glb → fleet.sitClip (the seated-drummer pose). GATED because a raw
// sit.glb fetch would break the ?classic=1 zero-fetch-delta covenant — F passes sit:true on the gig/default
// boot, sit:false (default) under ?classic. Absent/off → fleet.sitClip stays null and the drummer falls
// back to the standing-sunk hack, so this never breaks a boot. Does NOT touch fleet.ready / determinism.
export function loadPedFleet(base = 'models/peds/', { sit = false, look = false } = {}) {
const fleet = {
normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas
walkClip: null, idleClip: null, sitClip: null, lookClip: 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); } }));
if (sit) jobs.push(loadRig(`${base}sit.glb`).then(r => { // R16, gig/default boot only
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.sitClip = _rotOnly(c); } }));
// [R29 Spike 1] E's look.glb (mesh-free, 65 joints, 6.5 s 'Look'). Same gate as sit — the shell passes
// `look: !CLASSIC`, so ?classic fetches nothing and the zero-fetch-delta covenant holds. `_rotOnly` is
// correct here for the same reason as walk/idle: it's a STANDING clip, so there's no hip descent to
// recover and no foot re-plant needed (that trap was sit's alone — R16).
if (look) jobs.push(loadRig(`${base}look.glb`).then(r => {
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.lookClip = _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; } });
// FACING NORMALISE (R13 debt #4 — the RY audit). The imported GLB fleet's visual front is local +Z,
// but EVERYTHING downstream assumes -Z: the sim's heading math (sim.js atan2(-dx,-dz)), C's pose ry
// (counter.stand / bandPoses / watchPoints / browse points), the placeholder (toes point -Z on
// purpose, placeholder.js:61), and the impostor bake/pick (impostor.js -Z convention). So the GLB was
// the lone outlier: keepers faced the back wall, browsers faced away, streamed walkers moon-walked
// (anti-travel), and band.js carried a local RY_FLIP=π to patch just the gig. Rotate the rig's own
// mesh 180° here so its front becomes -Z like the rest of the system — one place, everyone correct,
// near↔mid stays seamless (the impostor bakes this same figure), and the flip is purely visual (a
// Y-rotation leaves every bone's world-Y untouched → head-height/feet-plant + the no-giants gate and
// all seeded identity/determinism are byte-unchanged). See LANE_D_NOTES R13 + LANE_C_PUB.md pose contract.
inner.rotation.y = Math.PI;
const fig = new THREE.Group();
fig.add(inner);
fig.updateWorldMatrix(true, true);
// height-normalise so the crown sits at `height` above the feet, then plant feet.
// NB the Mixamo skeleton's ORIGIN is the hips (~mid-body), so the head bone's world Y in bind pose
// is head-above-hips (~half the standing height), NOT the standing height. Normalising by that alone
// makes every figure ~2× too tall (the R9 interior-giant blocker). Normalise by the feet→crown SPAN
// (top head bone minus lowest bone) so a requested 1.75 m rig is 1.75 m from sole to crown.
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); // crown (HeadTop_End is highest)
minY = Math.min(minY, _wp.y); // sole (lowest foot/toe bone)
}
});
const span = headY - minY; // feet→crown standing height in the rig's native units
if (headY > 1e-4 && span > 1e-4) {
inner.scale.setScalar(height / span);
} 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, seated = false } = {}) {
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);
let seatBone = null;
if (act) {
act.time = phase * (act.getClip().duration || 0); act.play();
// R16 SEATED re-plant (OPT-IN — standing spawns skip this, so keepers/browsers/band are byte-identical).
// buildFigure planted the BIND pose; a sit clip lowers the hips + swings the legs, so re-plant the POSED
// feet to y=0: tick the mixer once to bake the pose, find the lowest bone, drop the figure so the soles
// return to the deck. One-time (a drummer's feet don't travel); this recovers the hip descent that
// _rotWithHips leaves out by dropping the (scale-unsafe) Hips.position track.
if (seated) {
mixer.update(0);
inner.updateWorldMatrix(true, true);
let m = Infinity;
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); m = Math.min(m, _wp.y); } });
if (isFinite(m)) inner.position.y -= m;
// R17 pelvic-lean: cache the mid-spine bone so seatedLean() can tilt the torso forward each frame (the
// sit clip can't — Hips.quaternion is unusable on these rigs). Seated-only; standing rigs get no seatBone.
inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; });
}
}
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
return { fig, inner, mixer, height, head, seatBone, dispose };
}
// R17 pelvic-lean: tilt a seated fig's torso forward, applied AFTER the mixer each frame. The mixer resets
// the bone's quaternion from the clip every frame, so this is a CONSTANT offset (not an accumulation). Only
// seated figs (drummer, bench-sit loiterers) call it; standing rigs have no seatBone → never leaned.
const _LEAN_AXIS = new THREE.Vector3(1, 0, 0);
const _LEAN_Q = new THREE.Quaternion();
export function seatedLean(spine, angle = 0.34) {
if (spine) spine.quaternion.multiply(_LEAN_Q.setFromAxisAngle(_LEAN_AXIS, angle));
}
// ---- 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, sitClip = null, lookClip = null, nominalHeight = 1.75 } = {}) {
if (!rig || !rig.scene) return null;
const { fig, inner, head, nominalHeight: nom } = buildFigure(rig, nominalHeight);
const bindPlantY = inner.position.y; // R17 bench-sit: the standing feet-plant, to restore after a sit
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)));
// R17 bench-sit: a third action, PLAYED ONLY when setSitting(true) — so walkers (and ?classic, where
// sitClip is null) are byte-identical: sitA never plays, the plant never moves, setSitting is a no-op.
const sitA = sitClip ? _action(mixer, inner, sitClip) : null;
// [R29 Spike 1] the glance — same opt-in shape as sitA: PLAYED ONLY when setLooking(true), so walkers,
// placeholders and ?classic (lookClip null) are byte-identical — lookA never advances the mixer.
const lookA = lookClip ? _action(mixer, inner, lookClip) : null;
let seatBone = null;
if (sitA) inner.traverse(o => { if (!seatBone && o.isBone && /Spine1$/i.test(o.name)) seatBone = o; });
// [R29] foot bones, cached once for the per-frame plant (see plantFeet). Only built when a posed clip
// exists, so walkers/?classic pay nothing.
let footBones = null;
if (lookA) { const fb = []; inner.traverse(o => { if (o.isBone && /(Toe|Foot)/i.test(o.name)) fb.push(o); }); footBones = fb.length ? fb : null; }
let sitting = false, looking = false;
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
}
// R17 bench-sit: snap the loitering ped into / out of the sit pose (no crossfade — a background ped
// sitting instantly is fine, and it avoids the crossfade fighting the discrete foot-replant). The sim's
// per-citizen scale is already on `fig`, so the re-plant is SCALE-AWARE: drop `inner` by the posed
// lowest-bone height / scale so the seated feet land on the footpath. Restore the standing plant on stand.
function setSitting(s) {
if (!sitA || s === sitting) return;
sitting = s;
moving = null; // force the next setMoving to re-apply after standing up
if (s) {
if (walkA) walkA.setEffectiveWeight(0);
if (idleA) idleA.setEffectiveWeight(0);
sitA.reset().play().setEffectiveWeight(1);
mixer.update(0); // bake the sit pose before measuring
inner.updateWorldMatrix(true, true);
let w = Infinity;
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); w = Math.min(w, _wp.y); } });
const S = fig.scale.y || 1;
if (isFinite(w)) inner.position.y -= (w - fig.position.y) / S; // posed soles → the footpath
fig.userData.procitySeated = true;
} else {
sitA.stop();
inner.position.y = bindPlantY; // restore the standing feet-plant
if (idleA) idleA.setEffectiveWeight(1);
fig.userData.procitySeated = false;
}
}
// [R29 Spike 1] the standing glance during a window-shop loiter. Deliberately SIMPLER than setSitting:
// it's a standing clip, so there is no posed foot-replant and no scale trap — the feet never leave the
// bind plant. Unlike sit (a snap, which had to avoid a crossfade fighting the discrete replant), this
// crossfades: idle → idle-variant reads naturally, and the sim only calls it while the ped is stopped.
// No-op when lookClip is absent (?classic / ?noassets) ⇒ byte-identical by construction.
function setLooking(l, fade = 0.3) {
if (!lookA || l === looking) return;
looking = l;
moving = null; // force the next setMoving to re-apply on the way out
if (l) {
lookA.reset().play();
if (idleA) idleA.crossFadeTo(lookA, fade, false);
else lookA.setEffectiveWeight(1);
if (walkA) walkA.setEffectiveWeight(0);
} else {
if (idleA) { idleA.reset().play(); lookA.crossFadeTo(idleA, fade, false); }
else lookA.setEffectiveWeight(0);
inner.position.y = bindPlantY; // drop the plant correction — back to the standing bind plant
}
}
// [R29] MEASURED, and it corrected my own assumption: I expected a standing clip to need no re-plant
// ("no hip descent to recover"). It does. `_rotOnly` drops the Hips POSITION track to stay rig-height
// independent, so look.glb's authored weight-shift dip cannot move the hips — it comes out as the FEET
// rising instead: lowest bone ranges 0.009…+0.205 m over the loop vs idle's 0.04…0.00. A 20 cm hover.
// Sit's one-time re-plant can't fix it because the float VARIES per frame, so this is the per-frame twin:
// reset to the bind plant, measure the posed feet, drop `inner` by the float / scale. Absolute (reset →
// measure → correct), never incremental, so it cannot drift. The result reproduces the authored motion
// exactly — the hips dip relative to the ground instead of the feet leaving it. Scale-aware like sit's.
// Cost is paid ONLY by glancing peds (a handful), and only on frames their mixer actually ran.
function plantFeet() {
if (!footBones || !looking) return;
inner.position.y = bindPlantY; // reference pose
inner.updateWorldMatrix(true, true);
let w = Infinity;
for (const b of footBones) { b.getWorldPosition(_wp); if (_wp.y < w) w = _wp.y; }
if (!isFinite(w)) return;
const S = fig.scale.y || 1;
inner.position.y -= (w - fig.position.y) / S; // posed soles → the footpath
}
// 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);
if (lookA) lookA.time = p * (lookA.getClip().duration || 1); // R29: glances desync too
}
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
return { fig, inner, mixer, head, seatBone, nominalHeight: nom, setMoving, setSitting, setLooking, plantFeet, setPhase, dispose,
hasClips: !!(walkA && idleA) };
}