Lane D (Citizens): round-3 support — setExposure day/night hook + design notes
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 <noreply@anthropic.com>
This commit is contained in:
parent
918a968185
commit
0985b05790
@ -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/*`)
|
||||
|
||||
@ -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<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
|
||||
|
||||
@ -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 ----
|
||||
|
||||
Loading…
Reference in New Issue
Block a user