The crowd comes alive. Default-on for the streamed roster (behind ?patronage=0); v1 path
(?roster=v1) untouched — no patron fields, golden identity holds. qa --strict GREEN.
Patronage (D1): per-ped state machine walking -> going (steer to a nearby open shop's door,
seeded ~every 10m within 18m) -> inside (hidden, 5-20s seeded dwell) -> emerge (resumes walk).
Hours-aware: noon = all types (up to ~38 inside, 7 types); night = video-only (the openLate shop
draws the crowd, roll ramps up as streets empty). Deterministic (150 chunk-keyed ids), leak-free
(0 GPU delta over the churn), budget-neutral (inside peds hidden -> draws -1).
Weather (D2): reads Lane B's PROCITY.weather contract (no import). setWeather({state,intensity}).
Rain -> density -56% (40-60% target), walk speed +14%, more sheltering (inside 6->25); overcast
-10%; clear = v1.
Also this session: R7 post-flip shell verification (no-flag boot streams, determinism, leak-free,
?roster=v1 restores golden roster) and pushed main + v1.0/v1.1/v2.0-alpha/v2.0-beta to origin.
F wiring (setShops door-point recipe, setWeather, ?patronage=0) documented in LANE_D_NOTES.
Only web/js/citizens/sim.js + web/citizens_test.html changed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
466 lines
33 KiB
Markdown
466 lines
33 KiB
Markdown
# LANE D — NOTES (measured budgets + clip wishlist)
|
||
|
||
*Written by PROCITY-D, 2026-07-14. Standalone verification via `web/citizens_test.html`.
|
||
Reference stack ported from `90sDJsim/web/world/index.html` ~405–520 (loadRig/spawnRig/_canon/
|
||
_rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra dev box.*
|
||
|
||
---
|
||
|
||
## ROUND 8 — shop patronage v0 + weather reaction (→ Lane F wiring below)
|
||
|
||
**The crowd comes alive: streamed peds duck into open shops they pass and re-emerge; at night only the
|
||
open-late video shop draws visitors; rain thins + shelters the crowd.** Default-on for the streamed
|
||
roster (post-flip), behind `?patronage=0` off-switch. Verified in-shell (real plan shops) + test page.
|
||
`qa.sh --strict` GREEN. v1 (`?roster=v1`) path untouched (no patron fields, golden identity holds).
|
||
|
||
### Patronage (D1) ✅
|
||
State machine per streamed ped: walking → (every ~10m, if a nearby OPEN shop is within 18m, a seeded
|
||
roll) → **going** (steer to the door) → **inside** (hidden, seeded 5–20s dwell) → **emerge** (resumes
|
||
its footpath walk). Hours-aware via the shop's `hours`.
|
||
- **Day-long**: peds visibly enter/leave shops of all types (noon: up to ~38 inside near a shopfront,
|
||
7 types visited).
|
||
- **Night**: at 22:48 only the video shop (`hours[1]≥22`) is open → patronage is **video-only**
|
||
(`onlyVideo:true`), and its block stays lively (the R6 night floor). The patronage roll ramps up as
|
||
the streets empty so the few peds out concentrate at the one open shop.
|
||
- **Deterministic** (chunk-keyed identities unchanged: 150 re-derive from seed), **leak-free** (0 GPU
|
||
delta over a 2-walk patronage churn — enter/emerge reuses the existing actor lifecycle), and
|
||
**budget-neutral / positive** (inside peds are hidden → draws −1 vs patronage-off at the same spot).
|
||
|
||
### Weather reaction (D2) ✅ — reads Lane B's `PROCITY.weather` contract (does NOT import weather.js)
|
||
`sim.setWeather({state,intensity})` each frame. **Rain** (intensity 0.8): density **−56%** (in the
|
||
40–60% target), walk speed +~14%, and patronage chance up → more peds shelter in shops (measured
|
||
inside 6→25). **Overcast**: ~−10%. **Clear**: v1 behaviour. Seed 20261990 rolls `clear` (v1-identical);
|
||
use `?weather=rain` to force it.
|
||
|
||
### → Lane F: wiring in `web/index.html` (F owns the shell)
|
||
1. **Shop door points** — build once from the plan, feed the sim:
|
||
```js
|
||
const CH = 64, shopsByChunk = new Map();
|
||
for (const s of plan.shops) {
|
||
const l = plan.lots.find(x => x.id === s.lot); if (!l) continue;
|
||
const ry = l.ry || 0, fx = -Math.sin(ry), fz = -Math.cos(ry); // facade normal → the street
|
||
const x = l.x + fx * (l.d / 2 + 0.6), z = l.z + fz * (l.d / 2 + 0.6); // door at the shopfront
|
||
const k = citizens.chunkKeyAt(x, z);
|
||
(shopsByChunk.get(k) || shopsByChunk.set(k, []).get(k)).push({ x, z, hours: s.hours });
|
||
}
|
||
if (!rosterV1) citizens.setShops(shopsByChunk); // patronage needs door points; inert without
|
||
if (params.get('patronage') === '0') citizens.setPatronage(false); // off-switch
|
||
```
|
||
(PATRON_RANGE 18m tolerates door-point imprecision; if a door reads wrong, nudge the `+0.6`.)
|
||
2. **Weather** — in the street branch each frame: `citizens.setWeather(window.PROCITY.weather);`
|
||
(B's contract is always a valid `{state,intensity}`; F sets `{clear,0}` when `?weather` is off.)
|
||
3. `setNightLivelyChunks` (openLate block) — already wired in F1; unchanged.
|
||
|
||
**Baseline note (flip protocol):** patronage default-on may move the flags-off draw baseline a hair —
|
||
in the direction of *fewer* draws (inside peds hidden), and the default spawn view has ~0 near-rigs so
|
||
it's near-nil. If F1's regression trips, re-pin per the flip protocol (it's an improvement, not a leak).
|
||
|
||
---
|
||
|
||
## ROUND 7 — MERGE VALIDATED · **GO for the roster flip** (→ Lane F: flip on this) · escape hatch ready
|
||
|
||
**Verdict: GO. Lane E's R7 ped-merge closed the one gap the R6 memo named. Full-density streamed
|
||
roster (perChunk 16) is now under budget in a real town. Flip it default-on.**
|
||
`qa.sh --strict` GREEN. All numbers in-shell (real town "Boolarra Heads", seed 20261990).
|
||
|
||
### Merge validation (E1 — `pipeline/merge_ped.py`, `447188a`) ✅
|
||
- **Every one of the 19 peds is now 1 mesh / 1 material** (was 8 meshes / 2 materials) → **~1 draw per
|
||
near-rig**; fleet **116→24 draws at the 24-cap** (E's own count: 92→19). Bonus: GPU memory dropped
|
||
too (geometries 205→160, textures 141→90 in-shell).
|
||
- Rigs **bind + animate** (walk/idle) — mixer 0.2–0.3 ms; **no T-pose**. Skinning/skeleton preserved
|
||
(65 joints), clips retarget unchanged.
|
||
- **Silhouettes + atlas materials intact** — hi-vis worker (vest/helmet/boots), suit, business, comical
|
||
kid all read crisp, no atlas seams/melting; **identity variety preserved** (`pickRig` unchanged).
|
||
- **Impostor bake clean** (19 subjects, 16×5 atlas); **determinism holds** (507 chunk-keyed identities
|
||
re-derive from seed).
|
||
|
||
### The flip numbers (full density, `perChunk 16` — no reduced-density concession)
|
||
| metric | pre-merge (R6) | **post-merge (R7)** | budget |
|
||
|---|---|---|---|
|
||
| worst continuous-walk street view **draws** | 356 ✗ | **241** ✅ | ≤300 |
|
||
| worst view **tris** | ~66k | **65,899** ✅ | ≤200k |
|
||
| worst-view near-rigs | (throttled to 8) | **17** (full) | cap 24 |
|
||
| draws / near-rig | ~7 | **~1** | — |
|
||
| 30-chunk soak (3 walks = 726 builds/disposes) | leak-free | **leak-free, 0 GPU delta** | baseline |
|
||
| heap over soak | 50→77→54 | 78→63 (GC, stable) | stable |
|
||
|
||
### Full-density shipping defaults (set this round)
|
||
`enableStream` default is now **`perChunk 16`, `radius 2`** (was 8). Near-cap stays 24 (now 24 draws,
|
||
not 168). `NIGHT_LIVELY_FLOOR 0.5` for the open-late block (R6). No other knobs change.
|
||
|
||
### → Lane F: the flip wiring in `web/index.html` (F owns the shell — decision #1, flip on my word)
|
||
Invert the R5/R6 flag: **stream is the default; `?roster=v1` is the escape hatch.**
|
||
```js
|
||
const rosterV1 = params.get('roster') === 'v1'; // escape hatch → the old fixed roster
|
||
const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed,
|
||
graph: plan.streets, fleet,
|
||
chunkStream: rosterV1 ? null : { radius: 2 } }); // default-on
|
||
```
|
||
Hours-aware (optional, recommended): after construction, `citizens.setNightLivelyChunks(keys)` where
|
||
`keys` = the openLate shop's lot→chunk + 1-ring (`sim.chunkKeyAt(x,z)`; openLate = `hours[1] >= 22`).
|
||
Everything else (`setTimeOfDay`/`setExposure`/`update`/`setPaused`) is unchanged. The
|
||
hook-driven window (`onChunkBuilt`/`onChunkDisposed` → `sim.onChunkBuilt/onChunkDisposed`) is still the
|
||
preferred precise option; poll works with zero wiring.
|
||
|
||
**Verified in my harness (test page + runtime-enabled shell):** no-flag boot → `streamMode:true`,
|
||
perChunk 16, determinism ✓; `?roster=v1` → `streamMode:false`, fixed roster, determinism ✓. The
|
||
in-shell no-flag-URL boot completes once F lands the two-line flip above — the roster itself is proven
|
||
to run correctly in the shell at the shipping default (the soak + flip numbers above are in-shell).
|
||
|
||
---
|
||
|
||
## 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.
|
||
`tools/qa.sh --strict` GREEN (sim.js doesn't touch the citygen/manifest gates).
|
||
|
||
**What it does.** v1 spreads one fixed roster over the whole town, so big-town streets are uniformly
|
||
sparse. Stream generates + ticks citizens **per 64m chunk**, keyed so identity is a pure function of
|
||
`(seed, chunkKey, i)` — independent of town size / visit order — and windows them to the live chunks
|
||
around the camera. Density becomes constant per unit street and follows the player. Everything
|
||
downstream (LOD tiers, 24 near-cap, mixer stagger, rig pool, impostor layer) is unchanged and stays
|
||
**global across live chunks**.
|
||
|
||
**Measured (test page, 9×9 fixture grid, ~±208m):**
|
||
- **Constant far-field density**: citizens within 70m ≈ 58 at spawn, 129 at 150m out, 83 elsewhere —
|
||
vs v1 (200 pop) which is uniformly ~12–15 everywhere (spread too thin). Stream is ~5–10× the street
|
||
density at equal total cost, and it doesn't thin as you walk arbitrarily far.
|
||
- **Deterministic**: 507 chunk-keyed identities re-derive byte-for-byte from the seed (determinism
|
||
button, stream mode). Same seed + same walk → same residents.
|
||
- **Leak-free**: after warming ped GPU uploads, **2 full grid walks (~380 chunk build/dispose cycles)
|
||
→ 0 geometry / 0 texture delta**; rig pool capped at 30, live chunks bounded at 25 (R=2 → 5×5).
|
||
- **Budget**: worst view uses the same near-cap (24) + 1 impostor draw as v1; frame 2.31 ms (~432 fps
|
||
headroom) with ~500 active (most far = cheap position-advance only). Citizen draw/tri contribution
|
||
is identical to v1's bound.
|
||
- **Flag-off = v1**: shell with no flag → `streamMode:false`, citizen 0 signature
|
||
`0:13:7:1.786:1.493:3:-1` — **exact match** to the R4 golden capture. No perturbation.
|
||
|
||
### → Lane F: wiring `?roster=stream` in `web/index.html` (F owns the shell)
|
||
Minimum (poll-driven, zero Lane B changes — works today):
|
||
```js
|
||
const streamOpt = params.get('roster') === 'stream' ? { radius: 2, perChunk: 16 } : null;
|
||
const citizens = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed,
|
||
graph: plan.streets, fleet, chunkStream: streamOpt });
|
||
```
|
||
Preferred (hook-driven — the sub-roster window then matches Lane B's built-chunk window exactly; the
|
||
sim auto-stops polling on the first hook call). Wire onto the chunks ctx **only when the flag is on**:
|
||
```js
|
||
if (streamOpt) {
|
||
chunks.ctx.onChunkBuilt = (key) => citizens.onChunkBuilt(key); // Lane B calls this (chunks.js:41)
|
||
chunks.ctx.onChunkDisposed = (key) => citizens.onChunkDisposed(key); // chunks.js:47
|
||
}
|
||
```
|
||
(`chunks` already exposes `ctx`; if not, expose it or pass the two callbacks into `createWorld`.)
|
||
Nothing else changes — `setTimeOfDay`/`setExposure`/`update`/`setPaused` are identical. Flag-off must
|
||
not construct with `chunkStream`, so the v1 path (golden identities) is untouched. The 64m chunk key
|
||
(`${cx},${cz}`, `Math.floor(v/64)`) matches Lane B's `planutil.js` exactly, so B's keys line up with ours.
|
||
|
||
*Hours-aware density is in (task 3): each chunk is thinned per-frame by the same `densityAt(tod)` curve
|
||
v1 uses, so streamed streets empty at night too. Open-shop spawn-bias weighting is left for v2-later
|
||
(needs shop data in the chunk; the sim is graph-only today) — noted, not built.*
|
||
|
||
---
|
||
|
||
## ROUND 4 — in-shell verify + decimation validation (→ Fable, → Lane F)
|
||
|
||
**All three R4 tasks done. `tools/qa.sh --strict` GREEN. Sign-off for Lane F below.**
|
||
|
||
### D1 — in-shell rig/keeper verification (`web/index.html`) ✅
|
||
Verified in the real game (not the test page), via `window.PROCITY`:
|
||
- **Placeholder→rig upgrade** fires for both peds (`citizens.mode` → `'rig'`, 19 fleet, pool active)
|
||
and keepers (keeper spawns at the counter `keeperStand` pose as a **rig**).
|
||
- **Leak-free**: baseline `renderer.info.memory` = {geometries 213, textures 198}; after **12
|
||
enter/exit shop cycles** it returned to **exactly** 213/198 (Δ geom 0, Δ tex 0). Interiors peak
|
||
~303 geoms inside, drop back every exit. Confirms the R-review dispose fix (free the keeper's
|
||
Skeleton only, never the fleet-shared geo/mats) holds in-shell alongside Lane C's interior dispose.
|
||
- **Determinism in-shell**: seed 20261990 → byte-identical identity signature across a full reload.
|
||
- **`?noassets=1`**: `fleet===null`, mode stays `placeholder`, peds + keeper are placeholder actors,
|
||
**zero ped GLB fetches** — town fully playable asset-free.
|
||
|
||
### D2 — decimated ped validation (critical path — E1 landed `518678d`) ✅ SIGN-OFF FOR F
|
||
Validated Lane E's decimated fleet (`web/models/peds/`, ref `lane-e/round4-peds`):
|
||
- **Per-ped tris: 1564–2805, avg 2450 — all ≤3k** (matches E's `_peds_decim.json`).
|
||
- **Rigs bind + animate**: skinning preserved, the shared `walk.glb`/`idle.glb` retarget unchanged
|
||
(skeleton/65 joints intact), near actors animate, mixer 0.1–0.3 ms.
|
||
- **No skinning explosions**: 24-teleport pool-eviction churn → **0 broken meshes**; silhouettes
|
||
read at street distance; identity variety intact (verified visually — kid/luchador/hi-vis/elder/
|
||
business all distinct, no melted hands/faces).
|
||
- **Impostor atlas bakes clean** from the decimated rigs; determinism + leak checks re-pass.
|
||
- **Gate-3 number for Lane F**: at the 24-rig near cap the fleet now contributes **≈59.6k tris**
|
||
(was ~1.2M). Test-page whole-view total **88.9k tris** (was ~1.5M). In-shell sample near rig 2729
|
||
tris, keeper 2073 tris (was 41k). **F is clear to re-measure gate 3** — the fleet leaves ~140k for
|
||
the town, comfortably ≤200k. (The R1 "peds too heavy" finding is now resolved by E's decimation.)
|
||
|
||
### D3 — exposure sync at dusk/night — FOUND + FIXED A REAL BUG ✅
|
||
Verifying the shell exposed a genuine rendering bug (only correct on my direct-canvas test page):
|
||
**the impostor material was `toneMapped:false` and applied ACES in-shader**, but the shell renders
|
||
through an **EffectComposer (RenderPass → bloom → OutputPass)**. three only tone-maps materials when
|
||
rendering to the **canvas** (`three.module.js:6921–6931`, `currentRenderTarget === null`); into a
|
||
composer target, materials output **linear** and OutputPass applies ACES once. So my self-tone-mapping
|
||
impostors were **double-tone-mapped** in the actual game (too dark), matching the rigs only on the
|
||
test page. **Fix (impostor.js):** the impostor is now a normal `toneMapped:true` material — it outputs
|
||
**linear** and uses three's own `<tonemapping_fragment>` + `<colorspace_fragment>` call-sites (the
|
||
`*_pars_*` are auto-injected — including them ourselves double-defines `RRTAndODTFit` and fails to
|
||
compile), so it tone-maps **identically to the near rigs on both paths**: canvas → ACES per-material;
|
||
composer → linear → OutputPass ACES. Verified: near rig + mid impostor match brightness at noon AND
|
||
the scene reads correctly at night 22:00 in the shell (`docs/shots/laneD/r4_shell_*.jpg`), and the
|
||
test-page canvas path still matches.
|
||
- **Note for Lane F**: `citizens.setExposure()` is now a **no-op** (exposure is global via
|
||
`renderer.toneMappingExposure`, which both three and OutputPass read). The call at
|
||
`web/index.html:245` is harmless — keep or drop it, your call.
|
||
|
||
*(Round-1/3 notes below remain valid; the tri-budget finding #1 is superseded by D2's decimation.)*
|
||
|
||
## What shipped
|
||
|
||
| file | role |
|
||
|---|---|
|
||
| `web/js/citizens/rigs.js` | ported rig stack: fleet loader, `_canon`/`canonRig`/`_rotOnly`, `buildFigure` (head-bone height-normalise + feet plant), `spawnRig` (single clip), `makeActor` (walk↔idle crossfade), `pickRig` |
|
||
| `web/js/citizens/placeholder.js` | seeded low-poly box humanoid (POLY lock) — walks/idles, planted at y=0, hot-swaps to a rig |
|
||
| `web/js/citizens/impostor.js` | 4-yaw sprite-atlas baker + instanced billboard layer (mid tier, 1 draw call) |
|
||
| `web/js/citizens/sim.js` | deterministic roster, footpath lanes, near/mid/far LOD, rig pool, staggered mixer budget, time-of-day density |
|
||
| `web/js/citizens/keepers.js` | one keeper per shop at the counter slot, idle + greet head/body-turn |
|
||
| `web/citizens_test.html` | standalone harness: fixture 3×3 street graph, sliders, tier debug, determinism check |
|
||
| `web/models/peds/*` | 19 rigged GLBs + `walk.glb` + `idle.glb`, **byte-identical** copies from 90sDJsim (checksummed) |
|
||
|
||
## The fleet (copied, never edited)
|
||
|
||
19 rigged peds (17 normal + 2 comical) + 2 clip-only GLBs, ~37 MB total, copied from
|
||
`johnking@100.91.239.7:~/Documents/90sDJsim/web/world/models/peds/` (also present locally at
|
||
`~/Documents/90sDJsim/…`). SHA-verified byte-identical (house law: canonical source stays upstream).
|
||
|
||
- Skeletons canonicalise cleanly: `mixamorig1…`→`mixamorig…`, so **one shared walk clip drives all 19**
|
||
and one shared idle clip too. Verified in-scene — a single `walk.glb` animates every ped.
|
||
- `walk.glb`/`idle.glb` position tracks + `Hips.quaternion` are stripped (`_rotOnly`) — without this
|
||
the different-scale root track inflates peds to giants. Confirmed: no giants/ants across all 19.
|
||
|
||
## Measured budgets (M3 Ultra, `citizens_test.html`)
|
||
|
||
Preview note: the in-app browser reports `visibilityState:'hidden'`, which throttles `requestAnimationFrame`,
|
||
so the on-screen fps counter reads low (47–84). True cost was measured by timing 140 manual
|
||
`update()+render()` frames.
|
||
|
||
**200 citizens, midday (the acceptance fixture):**
|
||
|
||
| metric | measured | CITY_SPEC / Lane-D budget | verdict |
|
||
|---|---|---|---|
|
||
| near (rigged) actives | 24 (hard cap) | ≤ 24 | ✅ |
|
||
| mid (impostor) | ~122 | — | — |
|
||
| far (culled) | ~54 | — | — |
|
||
| **mixer update / frame** | **0.4 ms max, ~0.2 ms avg** | < 2 ms | ✅ comfortably |
|
||
| sim logic / frame | 0.24 ms avg | 4 ms build budget | ✅ |
|
||
| full frame CPU (update+render) | ~2.6 ms → ~380 fps headroom | 60 fps (16.6 ms) | ✅ huge margin |
|
||
| mid tier draw calls | **1** (69 instances, 138 tris) | 1 per atlas | ✅ |
|
||
| impostor atlas texture | 2048×640 (16×5 cells, 4 yaws × 19 peds) | ≤ 2048, < 512 MB total | ✅ (~5 MB) |
|
||
| determinism | live roster ≡ seed recompute, stable while walking | same seed → same crowd | ✅ |
|
||
|
||
**Time-of-day density** (200 base): 00:00 → 12 active, 06:00 → 70, 12:00 → 200, 15:00 → 150,
|
||
21:00 → 36. Curve in `sim.js DAY_CURVE`.
|
||
|
||
## ⚠️ Findings for Lane E / Lane F (need a decision)
|
||
|
||
1. **The inherited peds are ~49.7k tris each — too heavy for the CITY_SPEC 200k-tri "typical view".**
|
||
24 near rigs ≈ **1.2M tris**; a realistic 16-rig street view already measured **1.02M tris**.
|
||
Framerate holds on M-series (GPU eats it), but this leaves *nothing* for Lane B buildings.
|
||
**Recommendation:** decimate the ped fleet to ~5–8k tris in the asset pipeline (Lane E) — at 6k/ped
|
||
the near cap costs ~144k tris, back within budget. The rig stack is indifferent to poly count;
|
||
this is purely an asset job. Until then, `NEAR_MAX` (sim.js) is the throttle.
|
||
|
||
2. **Draw calls scale with near rigs (~6 calls/ped — each GLB has several sub-meshes/materials).**
|
||
16 rigs ≈ 144 calls; 24 rigs ≈ ~190. Under the 300-call street budget *today*, but tight once
|
||
Lane B adds shells. **Recommendation:** merge each ped's sub-meshes by material on load
|
||
(`BufferGeometryUtils.mergeGeometries`) → ~1–2 calls/ped. Cheap win, deferred (not blocking).
|
||
|
||
3. **Peds are metallic-PBR (`metalness≈0.5`) and render black without a `scene.environment`.**
|
||
The test scene builds a neutral PMREM; the impostor bake is fed the same env. **Lane B's shell must
|
||
set `scene.environment`** (a sky PMREM) or every citizen — near and mid — goes dark. This is wired:
|
||
`CitizenSim` reads `scene.environment` and passes it to the impostor baker.
|
||
|
||
4. **Impostor material is `toneMapped:false` and does ACES + sRGB itself** (matching
|
||
`ACESFilmicToneMapping`, exposure `/0.6`). **The shell DOES animate `renderer.toneMappingExposure`**
|
||
(Lane B `lighting.js` sets it per day segment), so mid billboards drift brighter/darker than the near
|
||
rigs across the day unless matched. **Round-3 fix:** `CitizenSim.setExposure(e)` passthrough added —
|
||
see the Lane F hook below. (For a non-ACES curve the in-shader ACES in `impostor.js IMP_FRAG` would
|
||
also need to match; today both are ACESFilmic so exposure is the only variable.)
|
||
|
||
*Hardened by a 4-dimension adversarial code review (see D-progress.md): 6 confirmed defects fixed —
|
||
non-deterministic fleet order, shared-geometry disposal, impostor under-exposure, unrestored
|
||
viewport, missing modelMatrix, frozen exposure.*
|
||
|
||
## Integration hooks for Lane F
|
||
|
||
- `new CitizenSim({ renderer, scene, camera, citySeed, graph, fleet })` — `graph = { nodes:[{id,x,z}],
|
||
edges:[{id,a,b,width,kind}] }`. Swap the fixture graph for Lane A's `CityPlan.streets`. Footpath
|
||
lanes are derived from edge `width` + a 0.9 m margin; pedestrians keep to the right of travel.
|
||
- `sim.setPopulation(n)` (slider / density) · `sim.setTimeOfDay(t01)` (drive from the shell's day
|
||
segment) · `sim.setPaused(bool)` (wire to `visibilitychange`) · `sim.update(dt)` each frame.
|
||
- **[Round-3] `sim.setExposure(e)` — one line, needed for dusk/night.** The shell already calls
|
||
`citizens.setTimeOfDay(...)` in the street loop (`index.html:225`); add right after it:
|
||
```js
|
||
citizens.setExposure(renderer.toneMappingExposure); // [Lane F] keep mid billboards matched to day/night exposure
|
||
```
|
||
Cheap (sets one uniform), safe to call every frame, and survives the placeholder→rig atlas re-bake
|
||
(stored internally). **Verified** in `citizens_test.html`: uniform tracks 0.55↔2.3 and persists
|
||
across the fleet upgrade. Without it, mid impostors read too bright at night / too dark at noon vs
|
||
the near rigs. *(This is a Lane-F seam edit in `index.html`; flagged here per the cross-lane note rule.)*
|
||
- **[Round-3] Keeper fleet-upgrade (enables F §3.4).** Keepers today are placeholder-only because F
|
||
builds `new KeeperManager({ camera, citySeed })` with no fleet. To upgrade them to shared GLB rigs,
|
||
pass the loaded fleet: `new KeeperManager({ camera, citySeed, fleet })`. `keepers.js` already picks a
|
||
rig when `fleet.ready` and falls back to a placeholder otherwise — no other change. **Dispose is
|
||
leak-free**: verified 15 enter/exit cycles with a rig keeper → `renderer.info.memory` geo/tex return
|
||
exactly to baseline (`_disposeInner` frees the clone's skeleton bone-texture only; shared fleet
|
||
geo/mats are preserved for siblings). Safe to spawn/`remove()` per interior enter/exit.
|
||
- **Chunk streaming (v1.5):** roster is currently whole-graph — fine for v1, but big towns look sparse
|
||
(see the design note **"Chunk-streamed roster"** below for the why + the full plan).
|
||
- **Keepers:** `keepers.spawn(roomGroup, { x, z, ry, shopId, type })` — feed `x,z,ry` from Lane C's
|
||
interior counter `places`; call on interior build, `keepers.remove(handle)` on dispose.
|
||
`keepers.update(dt, playerPos)` each interior frame.
|
||
- `?noassets=1` verified: full placeholder town, mixers=0, zero crashes.
|
||
|
||
## Chunk-streamed roster — design note (✅ IMPLEMENTED in R5, behind `?roster=stream`)
|
||
|
||
*Round-3 task 3 (design), built in Round 5 — see the "ROUND 5" section at the top for the as-built
|
||
result + Lane F wiring. This section is the original spec; the implementation followed it: hook-driven
|
||
(`onChunkBuilt`/`onChunkDisposed`) with a poll fallback, chunk-local identity, global near-cap, owner
|
||
despawn, constructor opt-in (`chunkStream`). Kept for the rationale.*
|
||
|
||
**The problem (measured).** `setPopulation(N)` builds one global roster and spreads it uniformly over
|
||
*all* edges (`identityOf` picks `edge = rng()*edgeCount` across the whole graph). On the 12-edge test
|
||
fixture that fills the view. On a real town — "Boolarra Heads" has hundreds of edges — those same N
|
||
citizens scatter town-wide, so almost all sit beyond the 70 m cull from any one camera (I measured a
|
||
27-node generated town: pop 200 → **4 near / 6 mid / 190 far**). Raising N to fill the near streets
|
||
wastes roster + advance cost on citizens nobody can see, and still can't guarantee local density.
|
||
|
||
**The fix.** Generate + tick citizens **per chunk**, keyed so identity is independent of town size and
|
||
visit order, and stream them in lockstep with Lane B's chunk window (the chunks already building/
|
||
disposing around the player). Density becomes *constant per unit street*, and total roster is bounded
|
||
by the live-chunk window, not the town.
|
||
|
||
**Design:**
|
||
|
||
1. **Chunk-local identity.** Replace the global running `id` with a per-chunk key:
|
||
`identityOf(citySeed, chunkKey, i)` for `i in [0, perChunkCount)`, and choose the citizen's home
|
||
edge from **that chunk's** edges (`chunkIndex(plan).chunks[chunkKey].edges`), not the global list.
|
||
Then "same seed + same chunk → same people" holds regardless of what else exists or the order chunks
|
||
were visited — the determinism property that whole-graph keying quietly loses at scale. `perChunkCount`
|
||
is seeded per chunk and scaled by district (main-street chunks busier than residential) × `densityAt(tod)`.
|
||
2. **Windowing — poll-driven (works today, no Lane B change).** Each frame derive the active chunk set
|
||
from the camera: `chunkKey(⌊camX/chunkSize⌋, ⌊camZ/chunkSize⌋)` + neighbours within a radius R (R≥1
|
||
so a walker never steps into an unloaded chunk before its owner unloads). Diff vs the live set →
|
||
build sub-rosters for newly-active chunks, dispose for newly-inactive. This mirrors B's own window and
|
||
needs nothing from B. *Alternative — hook-driven:* `chunks.js` exposes an optional `ctx.onChunkBuilt`
|
||
(see `chunks.js:40` — "inert unless a consumer sets it"); if B also adds `onChunkDisposed`, drive
|
||
spawn/despawn off those instead of polling. Prefer this once B commits the pair; poll until then.
|
||
3. **Storage.** Swap the single `this.roster` array for `this.chunkRosters = Map<chunkKey, Citizen[]>`.
|
||
The per-frame `update()` iterates the union of live-chunk citizens. **Everything downstream is
|
||
unchanged** — LOD tiers, 24 near-cap, mixer stagger, rig pool, impostor layer already operate
|
||
per-citizen and are chunk-agnostic; the near-cap + mixer budget stay **global** across live chunks
|
||
(nearest-first selection already does the right thing on the merged set).
|
||
4. **Ownership + hand-off.** A citizen is owned by its spawn chunk. It walks freely (the graph is
|
||
continuous); because R≥1 keeps neighbours loaded, crossing a chunk boundary is seamless. It despawns
|
||
only when **its owner chunk** unloads — even if it's momentarily standing in a still-loaded neighbour
|
||
(acceptable: that's behind the player, past the cull). No re-keying on cross, so no identity churn.
|
||
5. **Budget.** Live window ≈ (2R+1)² chunks × `perChunkCount`. Tune `perChunkCount` (~15–30) so the
|
||
near streets reach the 24-cap while total roster stays ~150–270 — similar cost to today's flat 200,
|
||
but concentrated where the camera is instead of smeared across the map.
|
||
|
||
**Migration.** Opt-in and back-compatible: `new CitizenSim({ …, chunkStream: { chunkIndex, chunkSize,
|
||
radius } })` switches on chunk-keyed identity + windowing; omit it and the current whole-graph roster is
|
||
unchanged. So v1 ships as-is and v1.5 flips a constructor option — no rewrite of the hot path.
|
||
|
||
**Explicitly NOT in scope here:** cross-chunk social groups, per-district behaviour trees, persistent
|
||
citizens (a citizen you saw yesterday) — all v2 (`docs/V2_IDEAS.md`).
|
||
|
||
## Clip wishlist (for the mixamo-fetch run — CHECK THE 34 EXISTING CLIPS FIRST)
|
||
|
||
Only `walk` + `idle` are wired today (they ship with the ped fleet). CITY_SPEC says
|
||
`~/Documents/mixamo-fetch/out/` already holds **34 clips (sit, lean, look-around, phone, …)** — these
|
||
bind directly through the canonical `mixamorig` skeleton exactly like walk/idle, so wiring them is a
|
||
data task, not new rig work. **Verify each exists before requesting; never re-download.**
|
||
|
||
Wanted for richer loiter/keeper behaviour, in priority order:
|
||
1. `sit` — bench-sit at benches/verandah steps (sim loiter already has the hook; needs the clip).
|
||
2. `lean` — loiter against a shopfront (window-shopping stops).
|
||
3. `look-around` / `idle-look` — window-shopping variety + a better keeper greet than the body-turn.
|
||
4. `talk` / `gesture` — pairs chatting on the footpath (spawn in seeded 2-groups).
|
||
5. `carry` / laden walk — shoppers with bags after a purchase (content-phase tie-in).
|
||
6. `browse` / `reach` — keeper stocking shelves; customer reaching a shelf inside interiors.
|
||
|
||
Bespoke (won't be on Mixamo, hand-author later): crate-riffle at market stalls, till-operation.
|
||
|
||
## Known limitations (honest)
|
||
|
||
- Near↔mid swap is a hard LOD switch at ~25 m (hysteresis 24/27 m). At that range a 128px impostor
|
||
and the full rig subtend near-identical screen size, so it reads as seamless — but it is a switch,
|
||
not a cross-fade (rig materials are shared across SkeletonUtils clones, so per-instance opacity fade
|
||
isn't free). If a pop is ever visible after buildings land, the fix is a short dither/fade band.
|
||
- In `?noassets` mode, mid impostors use 8 generic placeholder variants while near placeholders are
|
||
per-citizen coloured — so a citizen's colour can shift slightly crossing 25 m. Fallback-only; with
|
||
the real fleet, near and mid are the same baked ped.
|
||
- Loiter *timing* is dt-driven (cosmetic), so exact positions at time T aren't reproducible across
|
||
runs; **identity** (who, which ped, height, speed, spawn beat) is fully seeded and asserted.
|