From 0985b0579069d2646f857a1bf3020be2bc29fda7 Mon Sep 17 00:00:00 2001 From: m3ultra Date: Tue, 14 Jul 2026 15:55:25 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20D=20(Citizens):=20round-3=20support=20?= =?UTF-8?q?=E2=80=94=20setExposure=20day/night=20hook=20+=20design=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fable round-3 §Lane D (all three tasks): - sim.js: add CitizenSim.setExposure(e) passthrough so mid-tier billboards track the shell's per-segment renderer.toneMappingExposure (lighting.js animates it); stored so it survives the placeholder->rig atlas re-bake. Verified: uniform tracks 0.55<->2.3 and persists across the fleet upgrade. - Keeper GLB-rig upgrade contract verified leak-free (my side): 15 enter/exit cycles with a fleet-backed rig keeper leave renderer.info.memory geo/tex exactly at baseline. Enabling it in the shell is one arg (pass `fleet` to KeeperManager) once F's §3.4 lands. - LANE_D_NOTES: exact Lane F call sites for setExposure + keeper fleet-upgrade, and the chunk-streamed roster v1.5 design note (chunk-local identity, windowing, ownership/hand-off, budget, opt-in migration) — design only, no implementation. Code touches only sim.js; no sibling files edited. Determinism still passes, near cap 24, mixer 0.3ms, no console errors. Co-Authored-By: Claude Opus 4.8 --- D-progress.md | 22 ++++++++++ docs/LANES/LANE_D_NOTES.md | 82 ++++++++++++++++++++++++++++++++++---- web/js/citizens/sim.js | 9 +++++ 3 files changed, 106 insertions(+), 7 deletions(-) diff --git a/D-progress.md b/D-progress.md index 34dfe91..37335de 100644 --- a/D-progress.md +++ b/D-progress.md @@ -17,6 +17,28 @@ the browser on this machine: 0 dangling edges, 0 non-finite citizen coords, no errors. `plan.streets` is 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: + +1. **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 `KeeperManager` with no `fleet`). Verified **my side** of + the contract in `citizens_test.html`: a fleet-backed keeper spawns as a **rig**, and 15 enter/exit + spawn→greet→`remove()` cycles leave `renderer.info.memory` **exactly at baseline** (geo 140→140, + tex 175→175) → leak-free. Enablement for F is one arg (`fleet`); documented in LANE_D_NOTES. +2. **`ImpostorLayer.setExposure()` wired to day/night (task 2).** Added a clean `CitizenSim.setExposure(e)` + passthrough (sim.js) that also **survives the placeholder→rig atlas re-bake** (stored + re-applied). + Confirmed the shell *does* animate `renderer.toneMappingExposure` per 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`, after `setTimeOfDay`) in LANE_D_NOTES — it's + an F-owned seam edit, so I documented rather than edited it. +3. **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.js` + reality), 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/*`) diff --git a/docs/LANES/LANE_D_NOTES.md b/docs/LANES/LANE_D_NOTES.md index da7d0c3..056e440 100644 --- a/docs/LANES/LANE_D_NOTES.md +++ b/docs/LANES/LANE_D_NOTES.md @@ -70,10 +70,11 @@ so the on-screen fps counter reads low (47–84). True cost was measured by timi `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`). If Lane B changes the tone-mapping curve or animates - `renderer.toneMappingExposure`, call `sim.impostor.setExposure(e)` (and, for a non-ACES curve, - the in-shader ACES in `impostor.js IMP_FRAG` would need to match). Mid billboards otherwise drift - from the near rigs at the LOD seam. + `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 @@ -86,14 +87,81 @@ viewport, missing modelMatrix, frozen exposure.* 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. -- **Chunk streaming (TODO for F):** roster is currently whole-graph. The identity fn `identityOf(citySeed, - edgeCount, id)` is chunk-ready — key it by `chunkKey+i` per CITY_SPEC and spawn/despawn rosters with - Lane B's chunk build/dispose. The LOD + pool machinery is already per-frame and chunk-agnostic. +- **[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 — v1.5 design note (design only; no implementation) + +*Round-3 task 3. This is the durable fix for "big-town streets look empty." Written as a spec for a +future Lane D/F pass — nothing here is wired yet; v1 keeps the whole-graph roster.* + +**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`. + 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 diff --git a/web/js/citizens/sim.js b/web/js/citizens/sim.js index d9f1fc0..59d86a3 100644 --- a/web/js/citizens/sim.js +++ b/web/js/citizens/sim.js @@ -106,6 +106,7 @@ export class CitizenSim { this.mode = 'placeholder'; // 'placeholder' until the fleet is ready, then 'rig' this.rigPool = null; this.paused = false; // app sets this on a visibilitychange → hidden (pauses mixers) + this._exposure = null; // shell's live renderer.toneMappingExposure (day/night) → impostor match this._mixerCursor = 0; // round-robin cursor for the staggered mixer budget this._nearList = []; // reused scratch this._midList = []; @@ -168,6 +169,13 @@ export class CitizenSim { // ---- public controls ---- setPopulation(n) { this.target = Math.max(0, n | 0); this._ensureRoster(this.target); } setTimeOfDay(t01) { this.timeOfDay = t01; } + // Match mid-tier billboards to the shell's day/night exposure. The impostor atlas is baked at a + // fixed exposure and self-tone-maps (toneMapped:false), so as the shell animates + // renderer.toneMappingExposure across day segments the billboards would drift brighter/darker than + // the near rigs. Call this each frame with the live exposure. Stored so an atlas re-bake (the + // placeholder→rig upgrade) inherits it. (Exposure only — the atlas can't track per-segment sun/hemi + // colour; that would need a re-bake. Close enough for the LOD swap.) + setExposure(e) { this._exposure = e; if (this.impostor) this.impostor.setExposure(e); } setDebugTiers(on) { this.debugTiers = !!on; if (this.impostor) this.impostor.setTint(on ? tierColor.mid : 0xffffff); @@ -226,6 +234,7 @@ export class CitizenSim { this.impostor = new ImpostorLayer(atlas, { maxInstances: IMPOSTOR_MAX }); this.group.add(this.impostor.mesh); if (this.debugTiers) this.impostor.setTint(tierColor.mid); + if (this._exposure != null) this.impostor.setExposure(this._exposure); // survive the upgrade re-bake } // ---- lane math ----