PROCITY/D-progress.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

9.8 KiB
Raw Blame History

PROCITY-D — progress (Lane D · Citizens)

Status: complete and verified in-browser. 2026-07-14, by PROCITY-D (Opus 4.8). Standalone test page runs, all Lane-D acceptance gates pass. One cross-cutting finding for Lane E (ped poly-count) is flagged below and in docs/LANES/LANE_D_NOTES.md.

Re-verify after session resume (2026-07-14, PROCITY-D)

Prior session hit the token limit; resumed on a fresh account. Confirmed nothing rotted — all 5 citizen modules intact on disk with every adversarial-review fix present. Re-ran the gates live in the browser on this machine:

  • perf @ pop 200: max mixer 0.2 ms, sim CPU 0.148 ms/frame, near capped at 24
  • determinism: ✓ 200 identities match seed 20261990 (live vs two fresh recomputes) ✓
  • no T-pose / one shared walk clip / keeper at counter
  • ?noassets: placeholder town, 0.00 ms mixer, 2.4k tris, crash-free ✓
  • Integration contract PROVEN: built Lane A's real generatePlan(1234) → "North Cressy Reach" (27 nodes / 22 edges) and ran new CitizenSim({ …, graph: plan.streets }) with zero adapter — 0 dangling edges, 0 non-finite citizen coords, no errors. plan.streets is exactly the {nodes, edges} shape the sim consumes. Lane F §3.3 is a one-line construction (see below).

What I built (owns web/js/citizens/*, web/citizens_test.html, web/models/*)

file lines-ish what
web/js/citizens/rigs.js ~185 the ported crown-jewel rig stack — fleet loader, _canon/canonRig/_rotOnly, buildFigure (head-bone height-normalise + feet plant), spawnRig, makeActor (walk↔idle crossfade for the near tier), pickRig
web/js/citizens/placeholder.js ~150 seeded low-poly box humanoid (the POLY lock) — walks/idles, so the town is populated from frame one and in ?noassets mode
web/js/citizens/impostor.js ~210 mid-tier LOD: bakes each ped to a 4-yaw sprite atlas on an offscreen RT, one InstancedMesh of camera-facing billboards → 1 draw call for the whole mid crowd
web/js/citizens/sim.js ~330 the simulation — deterministic roster, footpath lanes off the street graph, near/mid/far tiers with hysteresis + hard near-cap, a reusable rig pool, staggered mixer budget, time-of-day density
web/js/citizens/keepers.js ~95 one keeper per shop at the counter, idle clip + greet turn toward the player
web/citizens_test.html ~310 standalone harness (fixture 3×3 street graph, no Lane A/B imports)
web/models/peds/* 21 GLBs, 37 MB 19 rigged peds + walk + idle, byte-identical copies from 90sDJsim (SHA-verified)
docs/LANES/LANE_D_NOTES.md measured budgets + clip wishlist
docs/shots/laneD/*.jpg 5 shots near / mid / main-street / tier-debug / noassets

Everything is plain ES modules, three from web/vendor/, all randomness via core/prng.js (Math.random() used nowhere in identity/placement). Loaders come from core/loaders.js.

Acceptance — every gate met

  • 200-citizen fixture holds 60fps — full frame CPU ~2.6 ms (≈380 fps of headroom on the M3 Ultra; GPU trivial). Preview's throttled rAF shows 4784 fps, but timed frames prove the margin.
  • Mixer update < 2 ms/frame — measured 0.4 ms max at 200 citizens (staggered: nearest 8 every frame, ≤4 more per frame).
  • No T-pose ever — fixed a real bug: freshly-promoted rigs were showing bind-pose for a frame; actors now evaluate their mixer at acquire time. Verified across the fleet in 01_near_tier.jpg.
  • No giants/ants — head-bone height normalisation verified across all 19 peds (1.551.95 m).
  • One shared walk clip animates every ped — the mixorig canonicalisation binds a single walk.glb to all 19; confirmed in-scene.
  • Same seed → same crowd — determinism button asserts the live roster equals a fresh seed recompute; holds while citizens walk (identity ≠ live position).
  • Screenshots into docs/shots/laneD/ (near, mid, busy main street, + tier-debug & noassets).
  • docs/LANES/LANE_D_NOTES.md — measured budgets + the mixamo-fetch clip wishlist.

Plus the LOD design points from the lane brief: near<25m rig+mixer walking footpath lanes & turning at nodes on a seeded choice with loiter stops; mid 2570m instanced impostors baked from the fleet (auto-in-sync); far culled; hysteresis so nobody pops; time-of-day density; ?noassets placeholder town; keepers at counters. All exercised in the test page.

Decisions worth your eyes

  1. Rig pool, not per-spawn clone. Near actors are pooled per ped-type and reused across citizens (SkeletonUtils.clone + mixer are the expensive bits) — bounded at 30 instances, cap 24 active, ≤3 new clones/frame to avoid hitches. Height is applied as an outer-group scale so pooled rigs are height-agnostic and reusable.
  2. Impostor colour management. The mid atlas is baked linear (no tone-map) and the billboard shader applies ACES + sRGB itself, so mid impostors match the ACES-tonemapped near rigs exactly — fixed an early bug where they rendered ~2× too dark.
  3. Figure origin at the feet. Both rigs and placeholders present a fig Group whose origin is the ground point, so the sim moves/rotates walkers without re-planting them each frame.

⚠️ One thing I need a ruling on (Lane E territory)

The inherited 90sDJsim peds are ~49.7k tris each. 24 near rigs ≈ 1.2M tris — far past the CITY_SPEC "≤200k tris typical view". It runs fine on Apple Silicon today, but it leaves no tri budget for Lane B's buildings. The rig stack is indifferent to poly count — this is an asset-pipeline decimation job (Lane E): at ~6k tris/ped the near cap fits budget. Until then NEAR_MAX in sim.js is the throttle. Same story, smaller, for draw calls (each ped is ~6 sub-meshes → merge on load). Details + numbers in the NOTES. Not blocking Lane D; flagging so it's on the integration radar.

Adversarial review pass (6 real bugs found + fixed)

After the build passed in-browser, I ran a 4-dimension multi-agent code review with an adversarial verify stage (14 agents). It confirmed 6 genuine defects (4 others refuted); all 6 are fixed and re-verified:

  1. [HIGH] Non-deterministic fleet orderfleet.normal/all were filled in GLB load-completion order, so pickRig's index (→ every identity) could differ across reloads/machines, silently breaking "same seed → same crowd". Fixed: fill fixed slots by PED_NAMES index, compact after.
  2. [HIGH] Shared-resource disposal_disposeInner disposed geometry/materials that SkeletonUtils.clone shares across all clones + the base rig; pool eviction would tear down GPU buffers still in use by sibling citizens. Fixed: dispose only the clone's own Skeleton (its bone texture) — never the shared geo/mats. Stress-tested (24 teleports forcing eviction churn) → 0 broken meshes.
  3. [HIGH] Impostor under-exposure — the in-shader ACES omitted three's /0.6 exposure normalisation, making mid billboards ~1.67× darker than the identical near rig at the swap boundary. Fixed: color *= uExposure / 0.6.
  4. [HIGH] Renderer viewport not restored — the atlas bake set per-cell viewport/scissor but only restored scissor-test; the async re-bake left the viewport on a 128px cell → the main scene could render into a corner. Fixed: save/restore viewport + scissor rect.
  5. [MED] Billboard ignored modelMatrix — impostors would desync from rigs if the citizens group carries a transform (Lane B chunk offsets). Fixed: fold modelMatrix into the billboard base.
  6. [LOW] Frozen tone-map exposure — added ImpostorLayer.setExposure() for day/night.

Also caught during re-verification: three injects its own RRTAndODTFit/ACES functions into any tone-mapped ShaderMaterial, colliding with mine. Fixed with toneMapped:false (I tone-map in-shader) + imp-prefixed helper names. Console is clean; determinism still passes; 60fps holds.

For Lane F integration (hooks are ready)

  • Street graph — verified, no adapter. plan.streets (Lane A) is byte-shape-compatible with the graph the sim wants. In the street branch of the shell:
    const sim = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed, graph: plan.streets, fleet });
    // per frame (street mode): sim.update(dt);   // LOD is by sim.camera.position — pass the render camera
    
    Note update(dt) takes only dt (it reads this.camera.position for LOD, which is what you want — LOD by what the viewer sees). Lane F §3.3's sim.update(dt, player.position) sig is loose; the 1-arg form is correct as long as the constructed camera is the render camera.
  • Density on big towns: a flat setPopulation(N) spreads citizens uniformly across all edges, so on a large plan most sit far-culled from any one viewpoint (correct + cheap, but streets read empty if N is small). For v1 pick N to suit town size; the durable fix is chunk-streamed rosters (below).
  • Roster identity identityOf(citySeed, edgeCount, id) is chunk-ready — key by chunkKey+i and stream rosters with Lane B's chunk build/dispose.
  • Keepers pull x,z,ry from Lane C's counter places; sim.setTimeOfDay from the shell day segment; sim.setPaused from visibilitychange; Lane B must set scene.environment (PBR peds go dark without it — already wired to be read from the scene).

Try it

cd web && python3 -m http.server 8130
# open http://localhost:8130/citizens_test.html          (full fleet)
# open http://localhost:8130/citizens_test.html?noassets=1  (placeholder town)
# open http://localhost:8130/citizens_test.html?seed=1234   (any seed)

Sliders: population 0200, time-of-day. Buttons: tier colours (green=near, yellow=mid, pink=far), determinism check. Drag=look, wheel=zoom, WASD=move focus.