PROCITY/docs/LANES/LANE_D_NOTES.md
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

8.4 KiB
Raw Blame History

LANE D — NOTES (measured budgets + clip wishlist)

Written by PROCITY-D, 2026-07-14. Standalone verification via web/citizens_test.html. Reference stack ported from 90sDJsim/web/world/index.html ~405520 (loadRig/spawnRig/_canon/ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra dev box.

What shipped

file role
web/js/citizens/rigs.js ported rig stack: fleet loader, _canon/canonRig/_rotOnly, buildFigure (head-bone height-normalise + feet plant), spawnRig (single clip), makeActor (walk↔idle crossfade), pickRig
web/js/citizens/placeholder.js seeded low-poly box humanoid (POLY lock) — walks/idles, planted at y=0, hot-swaps to a rig
web/js/citizens/impostor.js 4-yaw sprite-atlas baker + instanced billboard layer (mid tier, 1 draw call)
web/js/citizens/sim.js deterministic roster, footpath lanes, near/mid/far LOD, rig pool, staggered mixer budget, time-of-day density
web/js/citizens/keepers.js one keeper per shop at the counter slot, idle + greet head/body-turn
web/citizens_test.html standalone harness: fixture 3×3 street graph, sliders, tier debug, determinism check
web/models/peds/* 19 rigged GLBs + walk.glb + idle.glb, byte-identical copies from 90sDJsim (checksummed)

The fleet (copied, never edited)

19 rigged peds (17 normal + 2 comical) + 2 clip-only GLBs, ~37 MB total, copied from johnking@100.91.239.7:~/Documents/90sDJsim/web/world/models/peds/ (also present locally at ~/Documents/90sDJsim/…). SHA-verified byte-identical (house law: canonical source stays upstream).

  • Skeletons canonicalise cleanly: mixamorig1…mixamorig…, so one shared walk clip drives all 19 and one shared idle clip too. Verified in-scene — a single walk.glb animates every ped.
  • walk.glb/idle.glb position tracks + Hips.quaternion are stripped (_rotOnly) — without this the different-scale root track inflates peds to giants. Confirmed: no giants/ants across all 19.

Measured budgets (M3 Ultra, citizens_test.html)

Preview note: the in-app browser reports visibilityState:'hidden', which throttles requestAnimationFrame, so the on-screen fps counter reads low (4784). True cost was measured by timing 140 manual update()+render() frames.

200 citizens, midday (the acceptance fixture):

metric measured CITY_SPEC / Lane-D budget verdict
near (rigged) actives 24 (hard cap) ≤ 24
mid (impostor) ~122
far (culled) ~54
mixer update / frame 0.4 ms max, ~0.2 ms avg < 2 ms comfortably
sim logic / frame 0.24 ms avg 4 ms build budget
full frame CPU (update+render) ~2.6 ms → ~380 fps headroom 60 fps (16.6 ms) huge margin
mid tier draw calls 1 (69 instances, 138 tris) 1 per atlas
impostor atlas texture 2048×640 (16×5 cells, 4 yaws × 19 peds) ≤ 2048, < 512 MB total (~5 MB)
determinism live roster ≡ seed recompute, stable while walking same seed → same crowd

Time-of-day density (200 base): 00:00 → 12 active, 06:00 → 70, 12:00 → 200, 15:00 → 150, 21:00 → 36. Curve in sim.js DAY_CURVE.

⚠️ Findings for Lane E / Lane F (need a decision)

  1. The inherited peds are ~49.7k tris each — too heavy for the CITY_SPEC 200k-tri "typical view". 24 near rigs ≈ 1.2M tris; a realistic 16-rig street view already measured 1.02M tris. Framerate holds on M-series (GPU eats it), but this leaves nothing for Lane B buildings. Recommendation: decimate the ped fleet to ~58k tris in the asset pipeline (Lane E) — at 6k/ped the near cap costs ~144k tris, back within budget. The rig stack is indifferent to poly count; this is purely an asset job. Until then, NEAR_MAX (sim.js) is the throttle.

  2. Draw calls scale with near rigs (~6 calls/ped — each GLB has several sub-meshes/materials). 16 rigs ≈ 144 calls; 24 rigs ≈ ~190. Under the 300-call street budget today, but tight once Lane B adds shells. Recommendation: merge each ped's sub-meshes by material on load (BufferGeometryUtils.mergeGeometries) → ~12 calls/ped. Cheap win, deferred (not blocking).

  3. Peds are metallic-PBR (metalness≈0.5) and render black without a scene.environment. The test scene builds a neutral PMREM; the impostor bake is fed the same env. Lane B's shell must set scene.environment (a sky PMREM) or every citizen — near and mid — goes dark. This is wired: CitizenSim reads scene.environment and passes it to the impostor baker.

  4. Impostor material is toneMapped:false and does ACES + sRGB itself (matching ACESFilmicToneMapping, exposure /0.6). If Lane B changes the tone-mapping curve or animates renderer.toneMappingExposure, call sim.impostor.setExposure(e) (and, for a non-ACES curve, the in-shader ACES in impostor.js IMP_FRAG would need to match). Mid billboards otherwise drift from the near rigs at the LOD seam.

Hardened by a 4-dimension adversarial code review (see D-progress.md): 6 confirmed defects fixed — non-deterministic fleet order, shared-geometry disposal, impostor under-exposure, unrestored viewport, missing modelMatrix, frozen exposure.

Integration hooks for Lane F

  • new CitizenSim({ renderer, scene, camera, citySeed, graph, fleet })graph = { nodes:[{id,x,z}], edges:[{id,a,b,width,kind}] }. Swap the fixture graph for Lane A's CityPlan.streets. Footpath lanes are derived from edge width + a 0.9 m margin; pedestrians keep to the right of travel.
  • sim.setPopulation(n) (slider / density) · sim.setTimeOfDay(t01) (drive from the shell's day segment) · sim.setPaused(bool) (wire to visibilitychange) · sim.update(dt) each frame.
  • Chunk streaming (TODO for F): roster is currently whole-graph. The identity fn identityOf(citySeed, edgeCount, id) is chunk-ready — key it by chunkKey+i per CITY_SPEC and spawn/despawn rosters with Lane B's chunk build/dispose. The LOD + pool machinery is already per-frame and chunk-agnostic.
  • Keepers: keepers.spawn(roomGroup, { x, z, ry, shopId, type }) — feed x,z,ry from Lane C's interior counter places; call on interior build, keepers.remove(handle) on dispose. keepers.update(dt, playerPos) each interior frame.
  • ?noassets=1 verified: full placeholder town, mixers=0, zero crashes.

Clip wishlist (for the mixamo-fetch run — CHECK THE 34 EXISTING CLIPS FIRST)

Only walk + idle are wired today (they ship with the ped fleet). CITY_SPEC says ~/Documents/mixamo-fetch/out/ already holds 34 clips (sit, lean, look-around, phone, …) — these bind directly through the canonical mixamorig skeleton exactly like walk/idle, so wiring them is a data task, not new rig work. Verify each exists before requesting; never re-download.

Wanted for richer loiter/keeper behaviour, in priority order:

  1. sit — bench-sit at benches/verandah steps (sim loiter already has the hook; needs the clip).
  2. lean — loiter against a shopfront (window-shopping stops).
  3. look-around / idle-look — window-shopping variety + a better keeper greet than the body-turn.
  4. talk / gesture — pairs chatting on the footpath (spawn in seeded 2-groups).
  5. carry / laden walk — shoppers with bags after a purchase (content-phase tie-in).
  6. browse / reach — keeper stocking shelves; customer reaching a shelf inside interiors.

Bespoke (won't be on Mixamo, hand-author later): crate-riffle at market stalls, till-operation.

Known limitations (honest)

  • Near↔mid swap is a hard LOD switch at ~25 m (hysteresis 24/27 m). At that range a 128px impostor and the full rig subtend near-identical screen size, so it reads as seamless — but it is a switch, not a cross-fade (rig materials are shared across SkeletonUtils clones, so per-instance opacity fade isn't free). If a pop is ever visible after buildings land, the fix is a short dither/fade band.
  • In ?noassets mode, mid impostors use 8 generic placeholder variants while near placeholders are per-citizen coloured — so a citizen's colour can shift slightly crossing 25 m. Fallback-only; with the real fleet, near and mid are the same baked ped.
  • Loiter timing is dt-driven (cosmetic), so exact positions at time T aren't reproducible across runs; identity (who, which ped, height, speed, spawn beat) is fully seeded and asserted.