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>
This commit is contained in:
m3ultra 2026-07-14 22:56:39 +10:00
parent 52eb1090ef
commit 5db3155991
4 changed files with 107 additions and 4 deletions

View File

@ -1,5 +1,29 @@
# 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

View File

@ -6,6 +6,70 @@ _rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra
---
## ROUND 6 — streamed-roster "default-on readiness" memo (→ Fable + Lane F, for the R7 flip call)
**Verdict: functionally ready (deterministic, leak-free, hours-aware, composes) — but full-density
default-on is gated on one optimisation (ped sub-mesh merge). Ships budget-safe TODAY at reduced
density.** Flag stays default-off this round. `qa.sh --strict` GREEN (sim.js doesn't touch its gates).
All numbers below are in-shell (real town "Boolarra Heads", seed 20261990), stream runtime-enabled
(F wires `?roster=stream` in F2 — call-site in the R5 section below).
### Soak (gate-2 + far-field, `?roster=stream`) ✅
- **Leak-free**: 3 full street-graph walks = **242 chunk builds / 242 disposes → 0 GPU geometry / 0
texture delta** (renderer.info constant 205/141); **16 shop enter/exit cycles → 0 delta**. JS heap
oscillates 50→77→**54** MB (GC recovers — not monotonic, no leak).
- **Determinism**: 188 live chunk-keyed identities re-derive byte-for-byte from the seed, in-shell.
- **Flag-off byte-identical**: shell citizen-0 signature `0:13:7:1.786:1.493:3:-1` (R4 golden), still
exact after this round's changes. 0 console errors across the soak.
### Hours-aware density ✅ (implemented this round)
Each chunk thins per-frame by the same `densityAt(tod)` curve v1 uses. New: `setNightLivelyChunks(keys)`
gives the open-late block a night floor (`NIGHT_LIVELY_FLOOR=0.5`). Measured: the open-late **video**
shop (hours 1123) block holds ~half its crowd at night while ordinary streets go near-empty
(**ordinary 65→6, ~90% drop; open-late block 31→16**). **Lane F call-site**: compute the openLate
shop's lot→chunk (+ 1-ring) and `citizens.setNightLivelyChunks(keys)` once after enabling stream
(`sim.chunkKeyAt(x,z)` gives the key). Empty set = uniform thinning (safe default).
### Composition with `?dig=1`
The streamed roster is **street-tier only**: the shell frame loop calls `citizens.update` in the
street branch only, so the roster is fully inert while a shop/dig is open — no shared state, no fight.
Proven: enter/exit record shops (the dig context) with stream on is leak-free and the roster resumes
intact. (The riffle itself is Lane C+F's flag; couldn't trigger its raycast open via synthetic input
without pointer-lock, but the composition claim is mode-separation, which holds by construction.)
Same reasoning covers the all-on combo — roster shares no state with `winmap` (B glass) or `dig` (C).
### Perf / budget — the ONE gap ⚠️ (default-on blocker at full density)
- Stream vs v1 (pop 140) at the busiest node: frame **+0.29 ms** (0.89 vs 0.60 ms), draws ~parity;
most of the ~500 active are far = cheap position-advance only. Mixer stays 0.10.3 ms.
- **Draw budget**: worst continuous-walk street view must stay ≤300 (CITY_SPEC). Root cause: each
decimated ped is **8 sub-meshes but only 2 materials → ~7 draws per near-rig**. So a dense street of
near-rigs blows the budget:
| stream `perChunk` | worst-view draws | worst near | density (within 70m, median) |
|---|---|---|---|
| 16 | **356** ✗ | 15 | ~26 |
| 12 | 314 ✗ | 10 | ~18 |
| **8 (new default)** | **291** ✓ | 10 | ~13 |
So it ships **budget-safe at `perChunk=8` today** (~13 median within 70m — constant + camera-following
vs v1's uniform ~13 that *thins as the town scales*; and it concentrates **~10 near-tier rigs vs v1's
01** — the visible upgrade). Full density (`perChunk 16`, ~2× the crowd) needs the budget headroom.
### Recommendation for the R7 default-on flip
1. **Land the ped sub-mesh merge** (merge each ped's 8 sub-meshes by material → 2 → ~2 draws/rig).
Then `perChunk 16` worst-view ≈ 260 draws — full density under budget. It's a `rigs.js` fleet-load
optimisation and it is **F1-safe in practice**: v1's default/spawn view has ~0 near-rigs, so the
flags-off draw snapshot is unaffected — the merge only cuts draws where near-rigs cluster (the
stream path). Also helps v1 street draws + interior keepers. ~1 session; risk = skinned-mesh merge
(verify animation). **This is the single thing standing between stream and default-on.**
2. Until then: default-on is safe at `perChunk=8` (reduced density) — flip is a judgement call on
whether ~10 near-rigs + constant far-field beats v1 today, or wait for the merge for the full crowd.
*Known non-blockers: near↔mid is a hard LOD switch (documented, imperceptible at 25m); open-shop
spawn-bias beyond the night floor is v2-later (needs per-chunk shop data — the sim is graph-only).*
---
## ROUND 5 — chunk-streamed roster (v2, behind `?roster=stream`) ✅ → Lane F wiring below
**As-built implementation of the R3 design note (further down).** Default-off; v1 path byte-identical.

View File

@ -173,7 +173,7 @@ for (const n of nodes) {
// ---------- fleet + sim ----------
const fleet = loadPedFleet(NOASSETS ? 'models/peds/__none__/' : 'models/peds/');
const sim = new CitizenSim({ renderer, scene, camera, citySeed: SEED, graph, fleet,
chunkStream: STREAM ? { radius: 2, perChunk: 16 } : null });
chunkStream: STREAM ? { radius: 2 } : null }); // perChunk default 10 (street draw budget) — see sim.js
// ---------- keeper demo (mock counter, since Lane C hasn't landed) ----------
const counter = new THREE.Mesh(new THREE.BoxGeometry(3, 1.05, 1),

View File

@ -29,6 +29,7 @@ const NEAR_BIAS = 2.0; // currently-near citizens keep pri
const PLACEHOLDER_VARIANTS = 8; // impostor atlas subjects in asset-free mode
const FOOTPATH_MARGIN = 0.9; // m outside the carriageway edge
const IMPOSTOR_MAX = 220; // instanced billboards ceiling
const NIGHT_LIVELY_FLOOR = 0.5; // stream: "lively" chunks (open-late block) keep ≥this density at night
// time-of-day density curve: t01 in [0,1) over a day → crowd multiplier (CITY_SPEC: lunch rush,
// near-empty at night). Sampled at 8 control points, linearly interpolated.
@ -113,6 +114,7 @@ export class CitizenSim {
this.chunkRosters = new Map(); // chunkKey → Citizen[] (owned by that chunk)
this.chunkEdges = null; // chunkKey → [edge index] (built lazily on enable)
this._hookDriven = false; // Lane B's onChunkBuilt/Disposed drive us → stop polling
this._livelyChunks = new Set(); // chunkKeys that resist night thinning (e.g. the open-late block)
this._encountered = new Set(); // cumulative identity signatures seen (determinism proof)
this._dropKeys = []; // scratch: chunks to drop this frame
this._activeList = []; // this frame's active citizens (both modes) — test page reads it
@ -145,7 +147,11 @@ export class CitizenSim {
// downstream — LOD tiers, the 24 near-cap, mixer stagger, rig pool, impostor layer — is already
// per-citizen and chunk-agnostic and runs GLOBAL across live chunks, unchanged.
enableStream({ radius = 2, perChunk = 16 } = {}) {
// perChunk default 8 keeps the worst continuous-walk street view ≤300 draws in a real town TODAY.
// Ceiling cause: each decimated ped is 8 sub-meshes but only 2 materials → ~7 draws/near-rig, so a
// dense street of near-rigs blows the 300 budget. The clean unlock (LANE_D_NOTES R6 memo) is merging
// each ped's sub-meshes by material (8→2) — then perChunk can go back to ~16 with headroom.
enableStream({ radius = 2, perChunk = 8 } = {}) {
if (this.streamMode) return;
this.roster.forEach(c => this._releaseActor(c)); this.roster.length = 0; // tear down the v1 roster
if (!this.chunkEdges) this._buildChunkIndex();
@ -246,6 +252,11 @@ export class CitizenSim {
}
activeCitizens() { return this._activeList; }
streamEncountered() { return [...this._encountered].sort(); }
// hours-aware: mark chunk keys that stay lively at night (the open-late block). The shell computes
// these from the plan (openLate shop's lot → chunkKey, + neighbours) and passes them — the sim is
// graph-only so it can't derive them itself. Empty = every chunk thins uniformly by the day curve.
setNightLivelyChunks(keys) { this._livelyChunks = new Set(keys); }
chunkKeyAt(x, z) { return chunkKey(chunkCoord(x), chunkCoord(z)); }
// ---- graph → footpath lanes ----
_setGraph(graph) {
@ -434,8 +445,12 @@ export class CitizenSim {
const list = [];
const density = densityAt(this.timeOfDay);
if (this.streamMode) {
for (const arr of this.chunkRosters.values()) {
const activeCount = Math.round(arr.length * density);
const haveLively = this._livelyChunks.size > 0;
for (const [key, arr] of this.chunkRosters) {
// hours-aware: every chunk thins by the day curve, but "lively" chunks (the open-late block)
// keep a night floor so that street never goes dead while its shop is still open.
const d = (haveLively && this._livelyChunks.has(key)) ? Math.max(density, NIGHT_LIVELY_FLOOR) : density;
const activeCount = Math.round(arr.length * d);
for (let i = 0; i < arr.length; i++) {
if (i < activeCount) list.push(arr[i]);
else if (arr[i].tier !== 'far') { this._releaseActor(arr[i]); arr[i].tier = 'far'; }