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>
9.8 KiB
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 rannew CitizenSim({ …, graph: plan.streets })with zero adapter — 0 dangling edges, 0 non-finite citizen coords, no errors.plan.streetsis 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 47–84 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.55–1.95 m).
- ✅ One shared walk clip animates every ped — the
mixorigcanonicalisation binds a singlewalk.glbto 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 25–70m 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
- 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.
- 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.
- Figure origin at the feet. Both rigs and placeholders present a
figGroup 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:
- [HIGH] Non-deterministic fleet order —
fleet.normal/allwere filled in GLB load-completion order, sopickRig's index (→ every identity) could differ across reloads/machines, silently breaking "same seed → same crowd". Fixed: fill fixed slots byPED_NAMESindex, compact after. - [HIGH] Shared-resource disposal —
_disposeInnerdisposed 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 ownSkeleton(its bone texture) — never the shared geo/mats. Stress-tested (24 teleports forcing eviction churn) → 0 broken meshes. - [HIGH] Impostor under-exposure — the in-shader ACES omitted three's
/0.6exposure normalisation, making mid billboards ~1.67× darker than the identical near rig at the swap boundary. Fixed:color *= uExposure / 0.6. - [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.
- [MED] Billboard ignored
modelMatrix— impostors would desync from rigs if the citizens group carries a transform (Lane B chunk offsets). Fixed: foldmodelMatrixinto the billboard base. - [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 thegraphthe sim wants. In the street branch of the shell:
Noteconst 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 cameraupdate(dt)takes only dt (it readsthis.camera.positionfor LOD, which is what you want — LOD by what the viewer sees). Lane F §3.3'ssim.update(dt, player.position)sig is loose; the 1-arg form is correct as long as the constructedcamerais 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 bychunkKey+iand stream rosters with Lane B's chunk build/dispose. - Keepers pull
x,z,ryfrom Lane C's counterplaces;sim.setTimeOfDayfrom the shell day segment;sim.setPausedfromvisibilitychange; Lane B must setscene.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 0–200, time-of-day. Buttons: tier colours (green=near, yellow=mid, pink=far), determinism check. Drag=look, wheel=zoom, WASD=move focus.