v3.0-alpha — Friday night at the pub. New web/js/citizens/band.js (GigCrew): - Band trio on C's stage.bandPoses (deckY), distinct fleet peds (seeded per gig), each with a primitive instrument on the chest (guitar/mic/bass; E's GLBs drop in via opts.instrumentFor). - Crowd (<=8) at C's watchPoints on the gate-protected spawnRig path: dance:false = weight-shift idle, dance:true = bounce/sway/step with seeded phase (no metronome). - Deterministic from citySeed; placeholder crew under ?noassets. sim.js: setGig(venueShopId, on) gig-night surge — patronage pulls peds from further (GIG_RANGE) and ducks in harder (GIG_SURGE) at the venue; occupants drain at close per A's closing-time ruling. Guarded by _gigVenue==null so gig-off is byte-identical (prime flag law; goldens unmoved). Verified standalone (A withGigs + C buildInterior + D): band 3, crowd 8, dancing 3, all 11 figures human-sized (1.60-1.83m under a 4.0m ceiling; R10 no-giants holds), band faces audience + crowd faces stage, deterministic, leak-free (8 cycles, 0 delta). Crew ~40 draws. Surge: venue occupancy 1 -> peak 5, identity stable. Shot: docs/shots/laneD/r12_gig_night.jpg. Flag for C/F: band.js flips C's watchPoints/bandPoses ry by pi (RY_FLIP) — the fleet mesh's visual front is local +Z, not the -Z the comments assume. Remove RY_FLIP if C re-fronts the gig poses. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
27 KiB
PROCITY-D — progress (Lane D · Citizens)
Round 12 (v3.0-alpha) — the band and the crowd, behind ?gigs=1. (2026-07-15)
Friday night at The Thornbury Hotel: a band on C's stage and a mixed crowd at C's watch points.
New web/js/citizens/band.js (GigCrew) + a gig surge in sim.js. Verified against A's real
plan.gigs + C's real venue interior (F wires the ?gigs=1 shell seam last, so I drove A+C+D
directly in a standalone scene). Deterministic from citySeed; asset-free-safe (placeholder crew).
Band trio (task 1) — 3 rigs on stage.bandPoses at deckY, distinct fleet peds (seeded
shuffle per gig — a band with two identical members reads wrong), each with a primitive instrument
that rides the chest: guitar (strum-sway), mic stand (vocal sway), bass (strum-sway). E's GLBs drop
in via opts.instrumentFor(role) when they land. NB C's bandPoses roles are guitar/vocal/bass
(a front-line trio) — there is no drummer pose (C's drum riser is set-dressing); flagging in case a
drummer was expected.
Crowd (task 2) — up to 8 rigs at watchPoints on the same gate-protected spawnRig path.
dance:false → subtle weight-shift idle; dance:true → bounce + sway + step, seeded phase so the
crowd never metronomes. Verified 3/8 dancing (C's ~⅓).
Gig surge (task 3) — sim.setGig(venueShopId, on): while on, patronage pulls peds from further
(GIG_RANGE 34 m) and ducks in harder (GIG_SURGE 0.55) at the venue; occupants drain at close
per A's closing-time ruling (no special handling — _openAt + the dwell timer do it). Measured a
mock venue climb 1 → peak 5 occupants; identity signature unchanged; gig-off byte-identical
(guarded by _gigVenue==null, so the frozen goldens don't move — prime flag law).
Verified (standalone, fresh browser): band 3 · crowd 8 · dancing 3 · all 11 figures human-sized (heights 1.60–1.83 m, crown < 4.0 m ceiling — the R10 no-giants gate holds for the gig rigs) · band faces audience + crowd faces stage · deterministic (identical twice) · leak-free (8 spawn/dispose cycles, geo/tex delta 0). Budget: the crew adds ~40 draws (11 rigs + 3 instruments); the pub interior itself is the bulk — F measures the full gig-night ≤350 in the shell.
Figures human-sized relative to doors/fittings: yes — measured (1.60–1.83 m under a 4.0 m ceiling)
and shown in docs/shots/laneD/r12_gig_night.jpg (distinct band with instruments, mixed crowd watching).
⚠ Cross-lane flag for C/F — facing convention. C's watchPoints/bandPoses ry point the fleet
mesh 180° from where spawnRig plants it (verified in-scene: ry=π faced the band at the backdrop;
the watch-point ry faced the crowd at the door). The fleet mesh's visual front is its local +Z,
not the −Z the sim/keeper comments assume. band.js compensates with a RY_FLIP = π on spawn so
band→audience and crowd→stage. If C changes the gig-pose ry convention (or the fleet is re-fronted),
remove RY_FLIP. Worth a look at whether keepers/browsers are subtly back-to-front too.
For Lane F (wiring the seam):
import { GigCrew } from './js/citizens/band.js';
const crew = new GigCrew({ citySeed: plan.citySeed, fleet });
// on gig doors/on, inside the venue interior:
crew.spawn(room.group, { stage: room.stage, watchPoints: room.watchPoints, gig: tonight });
// each interior frame: crew.update(dt); on gig-off / room exit: crew.disposeAll();
citizens.setGig(gig.venueShopId, on); // street surge while doors/on; setGig(null,false) to end
Round 10 (blocker fix) — interior giants → human-sized. qa --strict GREEN. (2026-07-15)
Fixed the R9 ship-blocker: interior rig figures were ~2× too tall (keeper crown at 3.83 m in a 3.4 m room, clipping the ceiling). Measured live, not from notes.
- Root cause (rigs.js
buildFigure): it normalised scale byheight / headY, whereheadYis the head bone's world Y in bind pose. But the Mixamo skeleton's ORIGIN is the hips (~mid-body), soheadYis head-above-hips (~half the standing height) — not the feet→crown height. Result: every rig ~1.83–2.0× too tall. (NB Fable's "bind-pose 82 vs posed 180" hypothesis was close on the numbers but the head does NOT move when posed — bind crown 3.209 m vs idle-posed 3.205 m. The 82 is head-above-hips; the 175 is the true feet→crown span. So it's a hips-relative measure, not animation.) - Fix (one logic line): normalise by the feet→crown span =
headY - minY(both already computed). NowbuildFigure(rig, h)lands the crown at exactlyhabove planted feet. Verified all 19 fleet rigs → crown == 1.75 m (feet 0); live keeper in "Second Time Redfern" 3.83 m → 1.82 m. This one change fixes keepers, browser rigs, near-tier street rigs AND the impostor bake (all ridebuildFigure) — street peds looked fine only because they're mostly impostors, which are sized by requested height and so masked the bug. - Regression guard (required) — new
tools/qa/interior_scale_check.py(Playwright): enters a sample of open shops, injects browser rigs at every browse point, and asserts EVERY interior fig's crown ∈ [1.4, 2.0] m AND < roomdims.HAND feet planted. Wired intotools/qa.sh --strict(gate 6,run_gate⇒ a giant fails qa). Proven bidirectional: PASS on the fix (24 figs / 6 shops), FAIL on a reintroduced giant (crowns 3.4–4.05 m, all 6 shops red).qa.sh --strict= 6 passed, 0 failed. - Determinism + leak re-verified: same seed → same keeper height + ped (shop 18 → 1.823 m, idx 10, identical twice; buildFigure crown stable & == requested); 15 spawn/dispose cycles → geo/tex delta 0.
- Re-shot
laneD/r9_browsers_in_shop.jpg→laneD/r10_browsers_fixed.jpg(kept r9 as the documented before-state).
Figures human-sized relative to doors/fittings: yes — verified live (measured crowns 1.6–1.86 m
across 6 shop types under 3.4–4.0 m ceilings) and visible in r10_browsers_fixed.jpg (a hi-vis worker
and a hoodie browser both well under the ceiling, in proportion to the shelves and door).
Files touched (mine only): web/js/citizens/rigs.js (fix), tools/qa/interior_scale_check.py (new
gate), tools/qa.sh (+gate block, authorized by ROUND10 §Lane D), docs/shots/laneD/r10_*.jpg, docs.
Round 9 (v2.0 ship) — interior presence: occupancy truth + browser rigs. qa GREEN.
Built the D side of the one cross-lane seam (C browse points → D occupancy+browsers → F handoff).
Follow a ped into a shop and find them browsing. Full numbers + F wiring in LANE_D_NOTES.md.
- Occupancy truth (D1): patronage records who's inside which shop by shopId. New
citizens.occupancyOf(shopId) → {count, occupants:[{seed,enteredAt,pedIndex}]}. Cleaned on emerge/chunk-drop/stream-toggle. Deterministic (17 shops + occupants byte-equal across two runs). - Browser rigs:
keepers.jsgainedbrowse(faces shelf, no greet) +pedIndex(the browser IS the ped who ducked in) +seedKey. Validated 12 enter/exit cycles: count == min(occupancy,3) 12/12, worst 61 draws ≤350, leak-free (0 GPU delta), disposed every exit. Merged peds = ~1 draw each. - Consistency (D2): inside peds hidden on the street (0 rendered), re-emerge at the door.
- F handoff (verified by running F's exact wiring): enter →
occupancyOf→browsePoints.slice(0, min(count,3))→keepers.spawn({...browse:true, pedIndex}).web/index.htmlsetShopsdoor points needshopId: s.idadded. Closing-time handled by the dwell model (pending A's explicit ruling). - Evidence:
docs/shots/laneD/r9_browsers_in_shop.jpg. v1 path untouched.
Changed web/js/citizens/sim.js (occupancy) + keepers.js (browser mode) + citizens_test.html
(shopId in synthetic shops).
Round 8 (the crowd comes alive) — shop patronage v0 + weather reaction. qa GREEN.
Also did the R7 post-flip shell verification (F's flip landed) + pushed the tags to origin (main 1a20501→900b274; v1.0/v1.1/v2.0-alpha/v2.0-beta all on origin).
- Post-flip verify ✅ (real shell): no-flag boot → streamMode true, determinism (188 ids), leak-free
(0 delta), setNightLivelyChunks wired;
?roster=v1→ fixed roster, golden identity byte-identical. - Shop patronage v0 (D1) ✅: streamed peds duck into open shops they pass (going→inside→emerge, hidden
while inside), hours-aware — noon variety (up to ~38 inside, 7 types), night video-only (the
open-late shop draws the crowd). Deterministic (150 chunk-keyed ids), leak-free (0 GPU delta over the
churn), budget-neutral (draws −1, inside peds hidden). Behind
?patronage=0, default-on for stream. - Weather reaction (D2) ✅: reads B's
PROCITY.weather(no import). Rain → density −56% (40–60% target), walk speed +~14%, more sheltering (inside 6→25); overcast −10%; clear = v1. - v1 path untouched (no patron fields, golden identity). Full numbers + F's
setShops/setWeather/?patronage=0wiring inLANE_D_NOTES.md.
Only web/js/citizens/sim.js + web/citizens_test.html (synthetic-shop demo + flags) changed.
Round 7 (the flip) — MERGE VALIDATED, GO given to F, defaults + escape hatch ready. qa GREEN.
Validated Lane E's ped-merge (the gate my R6 memo named) and handed F the GO for the roster flip.
Full go/no-go numbers + F's two-line wiring at the top of LANE_D_NOTES.md.
- Merge validated (D1): all 19 peds now 1 mesh / 1 material (was 8/2) → ~1 draw/near-rig (fleet 116→24 at the cap); bind+animate/no-T-pose, silhouettes+atlas materials crisp (hi-vis/suit/ kid), impostor bake clean, identity variety + determinism preserved. GPU memory dropped too.
- The flip numbers (full density, perChunk 16): worst street view 241 draws ≤300 (was 356 unmerged), 65,899 tris ≤200k, 17 near-rigs; 30-chunk soak (726 builds/disposes) leak-free, 0 GPU delta, heap stable. → GO.
- Full-density defaults (D2):
enableStreamdefault flipped 8→16 (the merge affords it); near-cap stays 24 (now 24 draws not 168). - Post-flip + escape hatch (D3): test page + shell now default to stream;
?roster=v1restores the fixed roster (verified: streamMode false, determinism ✓). No-flag boot → streamMode true, determinism ✓. F'sweb/index.htmlflip wiring (invert to default-on +?roster=v1escape) documented; the in-shell no-flag-URL boot completes when F lands F1 (roster proven running in-shell at the shipping default via the in-shell soak above).
Only web/js/citizens/sim.js (perChunk default) + web/citizens_test.html (default-on + escape
hatch) changed. Committed atomically.
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 toperChunk 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'sonChunkBuilt/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 (chunkStreamopt-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 wiresweb/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.memoryback to exactly baseline, Δ0); determinism holds across reload;?noassets=1fully 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 standardtoneMapped:truematerial 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 atweb/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 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.