PROCITY/D-progress.md
m3ultra 5db3155991 Lane D round-6: harden streamed roster + default-on readiness memo
Flag ?roster=stream stays default-off; v1 path byte-identical (citizen-0 = R4 golden, passes
F1 flags-off regression). Full evidence memo in LANE_D_NOTES for the R7 default-on decision.

- Soak (in-shell, real town): 242 chunk builds/disposes over 3 walks -> 0 GPU geom/tex delta;
  16 shop enter/exit -> 0 delta; heap oscillates 50->77->54MB (GC, no leak); 188 chunk-keyed
  identities re-derive from seed; 0 console errors.
- Hours-aware density: added setNightLivelyChunks + NIGHT_LIVELY_FLOOR so the open-late video
  block stays lively at night (31->16) while ordinary streets go near-empty (65->6). F call-site
  documented (openLate lot -> chunkKey via sim.chunkKeyAt).
- Composition with ?dig=1: roster is street-tier, inert while interior/dig open (frame loop is
  street-branch-only); enter/exit record shops leak-free with stream on. No shared state with any
  v2 flag -> all-on combo safe.
- Budget gap found + made safe: each decimated ped is 8 sub-meshes / 2 materials = ~7 draws/rig,
  so full-density stream (perChunk 16) hits 356 draws (>300 street budget). Set default perChunk 8
  (291 draws, budget-safe today). The clean unlock for full-density default-on = merge ped
  sub-meshes by material (8->2 -> ~2 draws/rig -> perChunk 16 fits ~260); documented as R7 work,
  F1-safe (v1 spawn view has ~0 near-rigs).

Only web/js/citizens/sim.js + web/citizens_test.html changed (+ NOTES/progress).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 22:56:39 +10:00

17 KiB
Raw Blame History

PROCITY-D — progress (Lane D · Citizens)

Round 6 (harden streamed roster toward default-on) — DONE. qa GREEN. Flag still default-off.

Hardened ?roster=stream and wrote the default-on readiness memo (top of LANE_D_NOTES.md) so Fable + F can decide the R7 flip on evidence. In-shell (real town), stream runtime-enabled:

  • Soak green: 242 chunk builds/disposes over 3 street-graph walks → 0 GPU geom/tex delta; 16 shop enter/exit → 0 delta; heap oscillates 50→77→54 MB (GC recovers, no leak); 188 chunk-keyed identities re-derive from seed; 0 console errors; flag-off byte-identical (citizen-0 = R4 golden).
  • Hours-aware density (new): added setNightLivelyChunks + a night floor so the open-late video block stays lively at night (31→16) while ordinary streets go near-empty (65→6). F call-site documented.
  • Composition with ?dig=1: roster is street-tier, fully inert while interior/dig is open (frame loop street-branch-only); enter/exit record shops leak-free with stream on. No shared state with any v2 flag → all-on combo safe.
  • Perf/budget — the one gap: each decimated ped = 8 sub-meshes / 2 materials = ~7 draws/near-rig, so full-density stream (perChunk 16) hits 356 draws (over the 300 street budget). Set the default to perChunk 8 (291 draws, ≤300, budget-safe today) and documented the clean unlock — merge ped sub-meshes by material (8→2 → ~2 draws/rig → perChunk 16 fits at ~260) — as the single thing gating full-density default-on in R7 (F1-safe: v1 spawn view has ~0 near-rigs).

Only web/js/citizens/sim.js (night floor + default) + web/citizens_test.html (default perChunk) changed. Committed atomically; flag default-off; v1 path untouched.


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 — ~58129 citizens within 70m at spawn AND 150m out, vs v1's uniform ~1215 (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 (15642805, 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 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.