Fable round-3 §Lane D (all three tasks): - sim.js: add CitizenSim.setExposure(e) passthrough so mid-tier billboards track the shell's per-segment renderer.toneMappingExposure (lighting.js animates it); stored so it survives the placeholder->rig atlas re-bake. Verified: uniform tracks 0.55<->2.3 and persists across the fleet upgrade. - Keeper GLB-rig upgrade contract verified leak-free (my side): 15 enter/exit cycles with a fleet-backed rig keeper leave renderer.info.memory geo/tex exactly at baseline. Enabling it in the shell is one arg (pass `fleet` to KeeperManager) once F's §3.4 lands. - LANE_D_NOTES: exact Lane F call sites for setExposure + keeper fleet-upgrade, and the chunk-streamed roster v1.5 design note (chunk-local identity, windowing, ownership/hand-off, budget, opt-in migration) — design only, no implementation. Code touches only sim.js; no sibling files edited. Determinism still passes, near cap 24, mixer 0.3ms, no console errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
12 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).
Round 3 — support round (2026-07-14, PROCITY-D) — all 3 tasks done
Per docs/LANES/ROUND3_INSTRUCTIONS.md §Lane D. Committed Lane D landed as 9eb1acc first; then:
- Keeper GLB-rig upgrade + leak-free dispose (task 1). Gated on F's §3.4 (keepers are still
placeholder-only in the shell — F builds
KeeperManagerwith nofleet). Verified my side of the contract incitizens_test.html: a fleet-backed keeper spawns as a rig, and 15 enter/exit spawn→greet→remove()cycles leaverenderer.info.memoryexactly at baseline (geo 140→140, tex 175→175) → leak-free. Enablement for F is one arg (fleet); documented in LANE_D_NOTES. ImpostorLayer.setExposure()wired to day/night (task 2). Added a cleanCitizenSim.setExposure(e)passthrough (sim.js) that also survives the placeholder→rig atlas re-bake (stored + re-applied). Confirmed the shell does animaterenderer.toneMappingExposureper segment (lighting.js), so this is a real fix, not defensive. Verified: uniform tracks 0.55↔2.3 and persists across the upgrade. Left F the exact one-line call site (index.html:225, aftersetTimeOfDay) in LANE_D_NOTES — it's an F-owned seam edit, so I documented rather than edited it.- Chunk-streamed roster design note (task 3). Written in LANE_D_NOTES ("Chunk-streamed roster —
v1.5 design note"): chunk-local identity keying, poll- vs hook-driven windowing (with the
chunks.jsreality), global near-cap across live chunks, ownership/hand-off, budget, and an opt-in migration that leaves v1 untouched. Design only, no implementation as instructed.
Post-edit re-verify: no console errors, determinism ✓ (200 match seed), near capped 24, mixer 0.3 ms. Round-3 code touches only sim.js (my file); the rest is docs. No sibling files edited.
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.