Implements the R3 design note (was design-only). Default-off; the v1 whole-graph path is byte-identical when the flag is off (shell citizen-0 signature matches the R4 golden exactly). - chunk-local identity keyed (seed, chunkKey, i) — same seed + chunk -> same residents, independent of town size / visit order; home edge picked from that chunk's edges. - windowed spawn/despawn: hook-driven via Lane B's onChunkBuilt/onChunkDisposed (sim auto- detects and stops polling) + a camera-poll fallback that needs zero Lane B changes. - global near-cap + mixer stagger + rig pool + impostor layer unchanged, run across live chunks. - owner-chunk despawn (no cross-boundary re-keying); per-chunk time-of-day thinning (hours-aware). Proven on a 9x9 fixture grid: constant far-field density (~58-129 within 70m at spawn AND 150m out, vs v1's uniform ~12-15); deterministic (507 chunk-keyed identities re-derive from seed); leak-free (2 full grid walks / ~380 chunk cycles -> 0 geom/tex delta, pool capped 30); budget unchanged (near-cap 24 + 1 impostor draw, 2.31ms/frame). qa.sh --strict GREEN. Only web/js/citizens/sim.js + web/citizens_test.html changed. Flag + shell call-site documented in LANE_D_NOTES for Lane F to wire web/index.html. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
210 lines
15 KiB
Markdown
210 lines
15 KiB
Markdown
# PROCITY-D — progress (Lane D · Citizens)
|
||
|
||
## Round 5 (v2 foundations) — DONE. Chunk-streamed roster behind `?roster=stream`. qa GREEN.
|
||
|
||
Implemented the R3 design note (was design-only): the chunk-streamed roster, default-off, v1-identical
|
||
when off. Full numbers + Lane F wiring in `docs/LANES/LANE_D_NOTES.md` (top).
|
||
|
||
- **Chunk-local identity** keyed `(seed, chunkKey, i)` → same seed + chunk → same residents regardless
|
||
of town size / visit order; **windowed spawn/despawn** hook-driven (Lane B's `onChunkBuilt`/
|
||
`onChunkDisposed`, sim auto-detects) with a camera-poll fallback; **global near-cap** across live
|
||
chunks; **owner-chunk despawn** (no cross-boundary re-keying); v1 path untouched (`chunkStream` opt-in).
|
||
- **Proven** on a big 9×9 fixture grid: (a) constant far-field density — ~58–129 citizens within 70m
|
||
at spawn AND 150m out, vs v1's uniform ~12–15 (fixed roster spread thin); (b) deterministic — 507
|
||
chunk-keyed identities re-derive from seed; (c) leak-free — 2 full grid walks (~380 chunk cycles) →
|
||
**0 geom/tex delta**, pool capped 30, chunks bounded 25; (d) budget — same near-cap + 1 impostor
|
||
draw as v1, 2.31 ms/frame; (e) **flag-off byte-identical** — shell citizen-0 signature exactly
|
||
matches the R4 golden. Hours-aware density included (per-chunk tod thinning).
|
||
- Only `web/js/citizens/sim.js` + `web/citizens_test.html` (bigger fixture + stream toggle/HUD/
|
||
determinism) changed. Flag + F call-site documented in LANE_D_NOTES; F wires `web/index.html`.
|
||
Evidence: `docs/shots/laneD/r5_stream_tiers.jpg`.
|
||
|
||
---
|
||
|
||
## Round 4 (v1-tag round) — DONE. qa.sh --strict GREEN.
|
||
|
||
All three Lane-D R4 tasks complete; details + numbers in `docs/LANES/LANE_D_NOTES.md` (top).
|
||
|
||
- **D1 — in-shell verify** ✅ In the real game (`web/index.html`): peds + keepers upgrade
|
||
placeholder→rig; **12 enter/exit shops leak-free** (`renderer.info.memory` back to exactly
|
||
baseline, Δ0); determinism holds across reload; `?noassets=1` fully placeholder, zero ped fetches.
|
||
- **D2 — decimated peds** ✅ (critical path; E1 landed) Validated E's fleet: **all 19 peds ≤3k tris
|
||
(1564–2805, avg 2450)**, rigs bind + animate (skinning/skeleton preserved, walk/idle retarget
|
||
unchanged), **0 broken meshes** under eviction churn, silhouettes + identity variety intact,
|
||
impostor atlas bakes clean, determinism + leak re-pass. **Gate-3 sign-off for Lane F: near fleet
|
||
now ≈59.6k tris at the 24-cap (was ~1.2M); whole test view 88.9k (was ~1.5M).**
|
||
- **D3 — exposure sync** ✅ Verifying it **found + fixed a real bug**: the impostor material
|
||
self-tone-mapped (`toneMapped:false` + in-shader ACES), which is correct on my direct-canvas test
|
||
page but **double-tone-mapped under the shell's EffectComposer/OutputPass** (three only tone-maps
|
||
materials on the canvas path). Reworked it to a standard `toneMapped:true` material that outputs
|
||
linear and uses three's own tone-map/colorspace chunks → tone-maps **identically to the near rigs
|
||
on both paths**. Verified near+mid match at noon and correct night render in-shell
|
||
(`docs/shots/laneD/r4_shell_*.jpg`). `citizens.setExposure()` is now a harmless no-op (exposure is
|
||
global); Lane F can keep or drop the call at `web/index.html:245`.
|
||
|
||
Only `web/js/citizens/impostor.js` changed this round (+ my NOTES/progress/R4 shots). Committed
|
||
atomically; `web/index.html`, `dbg.js`, and other lanes' in-flight files left untouched.
|
||
|
||
---
|
||
|
||
# (Round 1) — initial Lane D build
|
||
|
||
**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).
|
||
|
||
### 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:
|
||
|
||
1. **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 `KeeperManager` with no `fleet`). Verified **my side** of
|
||
the contract in `citizens_test.html`: a fleet-backed keeper spawns as a **rig**, and 15 enter/exit
|
||
spawn→greet→`remove()` cycles leave `renderer.info.memory` **exactly at baseline** (geo 140→140,
|
||
tex 175→175) → leak-free. Enablement for F is one arg (`fleet`); documented in LANE_D_NOTES.
|
||
2. **`ImpostorLayer.setExposure()` wired to day/night (task 2).** Added a clean `CitizenSim.setExposure(e)`
|
||
passthrough (sim.js) that also **survives the placeholder→rig atlas re-bake** (stored + re-applied).
|
||
Confirmed the shell *does* animate `renderer.toneMappingExposure` per 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`, after `setTimeOfDay`) in LANE_D_NOTES — it's
|
||
an F-owned seam edit, so I documented rather than edited it.
|
||
3. **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.js`
|
||
reality), 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 `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 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
|
||
|
||
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 0–200, time-of-day. Buttons: tier colours (green=near, yellow=mid, pink=far),
|
||
determinism check. Drag=look, wheel=zoom, WASD=move focus.
|