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

139 lines
9.8 KiB
Markdown
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-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 order** `fleet.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:
```js
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.