diff --git a/D-progress.md b/D-progress.md index fa5f961..2351c18 100644 --- a/D-progress.md +++ b/D-progress.md @@ -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 diff --git a/docs/LANES/LANE_D_NOTES.md b/docs/LANES/LANE_D_NOTES.md index fc59652..75e5c22 100644 --- a/docs/LANES/LANE_D_NOTES.md +++ b/docs/LANES/LANE_D_NOTES.md @@ -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 11–23) 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.1–0.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 + 0–1** — 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. diff --git a/web/citizens_test.html b/web/citizens_test.html index a906f95..3dc9837 100644 --- a/web/citizens_test.html +++ b/web/citizens_test.html @@ -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), diff --git a/web/js/citizens/sim.js b/web/js/citizens/sim.js index 26d30ae..b7ecbc5 100644 --- a/web/js/citizens/sim.js +++ b/web/js/citizens/sim.js @@ -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'; }