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:
parent
52eb1090ef
commit
5db3155991
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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'; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user