Lane D (Citizens): deterministic LOD crowd — rigs/impostors/keepers + test page

Populate the town at city scale, on budget, deterministic per-citizen identity:
- rigs.js — ported 90sDJsim rig stack: mixamorig canonicalisation so one shared
  walk clip drives all 19 peds, head-bone height-normalise, feet-plant, pooled
  near-tier actors with walk<->idle crossfade
- impostor.js — 4-yaw sprite-atlas baker + instanced billboards: whole mid crowd
  in 1 draw call, tone-matched to the ACES near rigs
- sim.js — deterministic roster, footpath lanes off the street graph, near/mid/far
  LOD with hysteresis + hard 24-cap, staggered mixer budget, time-of-day density
- placeholder.js — seeded low-poly box humanoids (frame-one population + ?noassets)
- keepers.js — one keeper per shop at the counter, idle + greet-turn
- 21 ped GLBs (byte-identical from 90sDJsim), citizens_test.html harness,
  LANE_D_NOTES.md, 5 beauty shots

Verified in-browser: 200 citizens at max mixer 0.2ms (budget 2ms), near capped 24,
determinism holds (200 identities match seed), no T-pose, ?noassets crash-free.
Consumes Lane A plan.streets with zero adapter. Six adversarial-review bugs fixed
(non-deterministic fleet order, shared-resource disposal on pool eviction,
impostor colour-management).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
m3ultra 2026-07-14 13:43:05 +10:00
parent 42350607a0
commit 9eb1acc61b
35 changed files with 1638 additions and 0 deletions

138
D-progress.md Normal file
View File

@ -0,0 +1,138 @@
# PROCITY-D — progress (Lane D · Citizens)
**Status: complete and verified in-browser.** 2026-07-14, by PROCITY-D (Opus 4.8).
Standalone test page runs, all Lane-D acceptance gates pass. One cross-cutting finding for Lane E
(ped poly-count) is flagged below and in `docs/LANES/LANE_D_NOTES.md`.
### Re-verify after session resume (2026-07-14, PROCITY-D)
Prior session hit the token limit; resumed on a fresh account. Confirmed nothing rotted — all 5
citizen modules intact on disk with every adversarial-review fix present. Re-ran the gates live in
the browser on this machine:
- **perf @ pop 200**: max mixer **0.2 ms**, sim CPU **0.148 ms/frame**, near capped at **24**
- **determinism**: `✓ 200 identities match seed 20261990` (live vs two fresh recomputes) ✓
- **no T-pose / one shared walk clip / keeper at counter**
- **`?noassets`**: placeholder town, 0.00 ms mixer, 2.4k tris, crash-free ✓
- **Integration contract PROVEN**: built Lane A's real `generatePlan(1234)` → "North Cressy Reach"
(27 nodes / 22 edges) and ran `new CitizenSim({ …, graph: plan.streets })` **with zero adapter**
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).
---
## What I built (owns `web/js/citizens/*`, `web/citizens_test.html`, `web/models/*`)
| file | lines-ish | what |
|---|---|---|
| `web/js/citizens/rigs.js` | ~185 | the ported crown-jewel rig stack — fleet loader, `_canon`/`canonRig`/`_rotOnly`, `buildFigure` (head-bone height-normalise + feet plant), `spawnRig`, `makeActor` (walk↔idle crossfade for the near tier), `pickRig` |
| `web/js/citizens/placeholder.js` | ~150 | seeded low-poly box humanoid (the POLY lock) — walks/idles, so the town is populated from frame one and in `?noassets` mode |
| `web/js/citizens/impostor.js` | ~210 | mid-tier LOD: bakes each ped to a 4-yaw sprite atlas on an offscreen RT, one InstancedMesh of camera-facing billboards → **1 draw call** for the whole mid crowd |
| `web/js/citizens/sim.js` | ~330 | the simulation — deterministic roster, footpath lanes off the street graph, near/mid/far tiers with hysteresis + hard near-cap, a reusable rig pool, staggered mixer budget, time-of-day density |
| `web/js/citizens/keepers.js` | ~95 | one keeper per shop at the counter, idle clip + greet turn toward the player |
| `web/citizens_test.html` | ~310 | standalone harness (fixture 3×3 street graph, no Lane A/B imports) |
| `web/models/peds/*` | 21 GLBs, 37 MB | 19 rigged peds + walk + idle, **byte-identical** copies from 90sDJsim (SHA-verified) |
| `docs/LANES/LANE_D_NOTES.md` | — | measured budgets + clip wishlist |
| `docs/shots/laneD/*.jpg` | 5 shots | near / mid / main-street / tier-debug / noassets |
Everything is plain ES modules, three from `web/vendor/`, all randomness via `core/prng.js`
(`Math.random()` used nowhere in identity/placement). Loaders come from `core/loaders.js`.
## Acceptance — every gate met
- ✅ **200-citizen fixture holds 60fps** — full frame CPU ~2.6 ms (≈380 fps of headroom on the M3
Ultra; GPU trivial). Preview's throttled rAF shows 4784 fps, but timed frames prove the margin.
- ✅ **Mixer update < 2 ms/frame** — measured **0.4 ms max** at 200 citizens (staggered: nearest 8
every frame, ≤4 more per frame).
- ✅ **No T-pose ever** — fixed a real bug: freshly-promoted rigs were showing bind-pose for a frame;
actors now evaluate their mixer at acquire time. Verified across the fleet in `01_near_tier.jpg`.
- ✅ **No giants/ants** — head-bone height normalisation verified across all 19 peds (1.551.95 m).
- ✅ **One shared walk clip animates every ped** — the `mixorig` canonicalisation binds a single
`walk.glb` to all 19; confirmed in-scene.
- ✅ **Same seed → same crowd** — determinism button asserts the live roster equals a fresh seed
recompute; holds while citizens walk (identity ≠ live position).
- ✅ **Screenshots** into `docs/shots/laneD/` (near, mid, busy main street, + tier-debug & noassets).
- ✅ **`docs/LANES/LANE_D_NOTES.md`** — measured budgets + the mixamo-fetch clip wishlist.
Plus the LOD design points from the lane brief: near<25m rig+mixer walking footpath lanes & turning
at nodes on a seeded choice with loiter stops; mid 2570m instanced impostors baked from the fleet
(auto-in-sync); far culled; hysteresis so nobody pops; time-of-day density; `?noassets` placeholder
town; keepers at counters. All exercised in the test page.
## Decisions worth your eyes
1. **Rig pool, not per-spawn clone.** Near actors are pooled per ped-type and reused across citizens
(SkeletonUtils.clone + mixer are the expensive bits) — bounded at 30 instances, cap 24 active,
≤3 new clones/frame to avoid hitches. Height is applied as an outer-group scale so pooled rigs are
height-agnostic and reusable.
2. **Impostor colour management.** The mid atlas is baked *linear* (no tone-map) and the billboard
shader applies ACES + sRGB itself, so mid impostors match the ACES-tonemapped near rigs exactly —
fixed an early bug where they rendered ~2× too dark.
3. **Figure origin at the feet.** Both rigs and placeholders present a `fig` Group whose origin is the
ground point, so the sim moves/rotates walkers without re-planting them each frame.
## ⚠️ One thing I need a ruling on (Lane E territory)
**The inherited 90sDJsim peds are ~49.7k tris each.** 24 near rigs ≈ 1.2M tris — far past the
CITY_SPEC "≤200k tris typical view". It runs fine on Apple Silicon today, but it leaves no tri budget
for Lane B's buildings. The rig stack is indifferent to poly count — this is an **asset-pipeline
decimation job (Lane E)**: at ~6k tris/ped the near cap fits budget. Until then `NEAR_MAX` in `sim.js`
is the throttle. Same story, smaller, for draw calls (each ped is ~6 sub-meshes → merge on load).
Details + numbers in the NOTES. Not blocking Lane D; flagging so it's on the integration radar.
## Adversarial review pass (6 real bugs found + fixed)
After the build passed in-browser, I ran a 4-dimension multi-agent code review with an adversarial
verify stage (14 agents). It confirmed **6 genuine defects** (4 others refuted); all 6 are fixed and
re-verified:
1. **[HIGH] Non-deterministic fleet order** — `fleet.normal/all` were filled in GLB load-completion
order, so `pickRig`'s index (→ every identity) could differ across reloads/machines, silently
breaking "same seed → same crowd". Fixed: fill fixed slots by `PED_NAMES` index, compact after.
2. **[HIGH] Shared-resource disposal** — `_disposeInner` disposed geometry/materials that
SkeletonUtils.clone *shares* across all clones + the base rig; pool eviction would tear down GPU
buffers still in use by sibling citizens. Fixed: dispose only the clone's own `Skeleton` (its
bone texture) — never the shared geo/mats. Stress-tested (24 teleports forcing eviction churn) →
0 broken meshes.
3. **[HIGH] Impostor under-exposure** — the in-shader ACES omitted three's `/0.6` exposure
normalisation, making mid billboards ~1.67× darker than the identical near rig at the swap
boundary. Fixed: `color *= uExposure / 0.6`.
4. **[HIGH] Renderer viewport not restored** — the atlas bake set per-cell viewport/scissor but only
restored scissor-*test*; the async re-bake left the viewport on a 128px cell → the main scene
could render into a corner. Fixed: save/restore viewport + scissor rect.
5. **[MED] Billboard ignored `modelMatrix`** — impostors would desync from rigs if the citizens group
carries a transform (Lane B chunk offsets). Fixed: fold `modelMatrix` into the billboard base.
6. **[LOW] Frozen tone-map exposure** — added `ImpostorLayer.setExposure()` for day/night.
Also caught during re-verification: three injects its own `RRTAndODTFit`/ACES functions into any
tone-mapped `ShaderMaterial`, colliding with mine. Fixed with `toneMapped:false` (I tone-map
in-shader) + `imp`-prefixed helper names. Console is clean; determinism still passes; 60fps holds.
## For Lane F integration (hooks are ready)
- **Street graph — verified, no adapter.** `plan.streets` (Lane A) is byte-shape-compatible with the
`graph` the sim wants. In the street branch of the shell:
```js
const sim = new CitizenSim({ renderer, scene, camera, citySeed: plan.citySeed, graph: plan.streets, fleet });
// per frame (street mode): sim.update(dt); // LOD is by sim.camera.position — pass the render camera
```
Note `update(dt)` takes **only dt** (it reads `this.camera.position` for LOD, which is what you want —
LOD by what the viewer sees). Lane F §3.3's `sim.update(dt, player.position)` sig is loose; the
1-arg form is correct as long as the constructed `camera` is the render camera.
- **Density on big towns**: a flat `setPopulation(N)` spreads citizens uniformly across *all* edges, so
on a large plan most sit far-culled from any one viewpoint (correct + cheap, but streets read empty
if N is small). For v1 pick N to suit town size; the durable fix is chunk-streamed rosters (below).
- Roster identity `identityOf(citySeed, edgeCount, id)` is chunk-ready — key by `chunkKey+i` and
stream rosters with Lane B's chunk build/dispose.
- Keepers pull `x,z,ry` from Lane C's counter `places`; `sim.setTimeOfDay` from the shell day segment;
`sim.setPaused` from `visibilitychange`; **Lane B must set `scene.environment`** (PBR peds go dark
without it — already wired to be read from the scene).
## Try it
```
cd web && python3 -m http.server 8130
# open http://localhost:8130/citizens_test.html (full fleet)
# open http://localhost:8130/citizens_test.html?noassets=1 (placeholder town)
# open http://localhost:8130/citizens_test.html?seed=1234 (any seed)
```
Sliders: population 0200, time-of-day. Buttons: tier colours (green=near, yellow=mid, pink=far),
determinism check. Drag=look, wheel=zoom, WASD=move focus.

124
docs/LANES/LANE_D_NOTES.md Normal file
View File

@ -0,0 +1,124 @@
# 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` ~405520 (loadRig/spawnRig/_canon/
_rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra dev box.*
## 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 (4784). 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 ~58k 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`) → ~12 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`). 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.
*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.
- **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.
- **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.
## 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.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

304
web/citizens_test.html Normal file
View File

@ -0,0 +1,304 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>PROCITY · Lane D — Citizens test</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; overflow: hidden; background: #12100e;
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; color: #e8e2d6; }
#app { position: fixed; inset: 0; }
#hud { position: fixed; top: 10px; left: 10px; width: 264px; padding: 12px 14px;
background: rgba(20,17,14,.82); border: 1px solid #3a3128; border-radius: 8px;
backdrop-filter: blur(6px); user-select: none; }
#hud h1 { margin: 0 0 8px; font-size: 12px; letter-spacing: .08em; color: #d8b23a; text-transform: uppercase; }
.row { display: flex; justify-content: space-between; gap: 8px; margin: 3px 0; }
.row .k { color: #9a8f7d; }
.row .v { color: #f0e8d0; font-variant-numeric: tabular-nums; }
.ctl { margin: 10px 0 4px; }
.ctl label { display: flex; justify-content: space-between; color: #c7bca8; margin-bottom: 3px; }
input[type=range] { width: 100%; accent-color: #d8b23a; }
.btns { display: flex; gap: 6px; margin-top: 10px; flex-wrap: wrap; }
button { flex: 1; min-width: 84px; padding: 6px 8px; cursor: pointer; color: #efe7d6;
background: #2c2620; border: 1px solid #4a3f31; border-radius: 6px; font: inherit; }
button:hover { background: #38302666; border-color: #6a5a44; }
button.on { background: #4a3d1f; border-color: #d8b23a; color: #ffe9a8; }
#det { margin-top: 8px; min-height: 15px; color: #8fd88f; }
#warn { color: #e0a24a; margin-top: 6px; }
hr { border: 0; border-top: 1px solid #3a3128; margin: 10px 0 6px; }
#help { position: fixed; bottom: 8px; left: 10px; color: #6f665a; font-size: 11px; }
.barwrap { height: 6px; background: #2a241d; border-radius: 3px; overflow: hidden; margin-top: 2px; }
.bar { height: 100%; background: linear-gradient(90deg,#6db36d,#d8b23a,#c8102e); width: 0%; }
</style>
<script type="importmap">
{ "imports": {
"three": "./vendor/three.module.js",
"three/addons/": "./vendor/addons/"
} }
</script>
</head>
<body>
<div id="app"></div>
<div id="hud">
<h1>Lane D · Citizens</h1>
<div class="row"><span class="k">fps</span><span class="v" id="fps"></span></div>
<div class="barwrap"><div class="bar" id="fpsbar"></div></div>
<div class="row"><span class="k">rigged (near)</span><span class="v" id="s-rig">0</span></div>
<div class="row"><span class="k">impostor (mid)</span><span class="v" id="s-mid">0</span></div>
<div class="row"><span class="k">culled (far)</span><span class="v" id="s-far">0</span></div>
<div class="row"><span class="k">active</span><span class="v" id="s-active">0</span></div>
<hr>
<div class="row"><span class="k">mixer ms</span><span class="v" id="s-mixer">0</span></div>
<div class="row"><span class="k">draw calls</span><span class="v" id="s-draws">0</span></div>
<div class="row"><span class="k">triangles</span><span class="v" id="s-tris">0</span></div>
<div class="row"><span class="k">rig pool</span><span class="v" id="s-pool">0</span></div>
<div class="row"><span class="k">mode</span><span class="v" id="s-mode"></span></div>
<div class="ctl">
<label>population <span class="v" id="l-pop">120</span></label>
<input type="range" id="pop" min="0" max="200" value="120">
</div>
<div class="ctl">
<label>time of day <span class="v" id="l-tod">12:00</span></label>
<input type="range" id="tod" min="0" max="1000" value="500">
</div>
<div class="btns">
<button id="btn-tiers">Tier colours</button>
<button id="btn-det">Determinism ✓</button>
</div>
<div id="det"></div>
<div id="warn"></div>
</div>
<div id="help">drag = look · wheel = zoom · WASD = move focus · Q/E = up/down</div>
<script type="module">
import * as THREE from 'three';
import { loadPedFleet } from './js/citizens/rigs.js';
import { CitizenSim, identityOf, signatureOf } from './js/citizens/sim.js';
import { pickRig } from './js/citizens/rigs.js';
import { KeeperManager } from './js/citizens/keepers.js';
const qs = new URLSearchParams(location.search);
const SEED = (parseInt(qs.get('seed'), 10) || 20261990) >>> 0;
const NOASSETS = qs.get('noassets') === '1';
// ---------- renderer / scene ----------
const app = document.getElementById('app');
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.05;
app.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xbcC6cf);
scene.fog = new THREE.Fog(0xbcC6cf, 90, 220);
const camera = new THREE.PerspectiveCamera(55, innerWidth / innerHeight, 0.1, 600);
// warm cinematic key + cool sky fill (the POLY lock)
const sun = new THREE.DirectionalLight(0xfff0d8, 2.2); sun.position.set(40, 70, 25); scene.add(sun);
scene.add(new THREE.HemisphereLight(0xbcd0f0, 0x4a4034, 1.15));
scene.add(new THREE.AmbientLight(0x2a2620, 0.4));
// neutral PMREM environment — the peds are metallic-PBR (metalness≈0.5) and render dark with no
// environment to reflect. Lane B's shell will provide the real sky env; the test scene makes its own.
// (Set BEFORE the sim is created so the rig-atlas bake reads scene.environment too.)
function makeEnv() {
const c = document.createElement('canvas'); c.width = 16; c.height = 64;
const x = c.getContext('2d'), g = x.createLinearGradient(0, 0, 0, 64);
g.addColorStop(0, '#e6eef7'); g.addColorStop(0.5, '#c2cad0'); g.addColorStop(1, '#726552');
x.fillStyle = g; x.fillRect(0, 0, 16, 64);
const tex = new THREE.CanvasTexture(c);
tex.mapping = THREE.EquirectangularReflectionMapping; tex.colorSpace = THREE.SRGBColorSpace;
const pm = new THREE.PMREMGenerator(renderer); const env = pm.fromEquirectangular(tex).texture;
tex.dispose(); pm.dispose(); return env;
}
scene.environment = makeEnv();
// ---------- fixture street graph (LOCAL — no Lane A/B imports) ----------
// a 3×3 lattice of nodes → a walkable loop network with turns at every junction.
const G = 40;
const nodes = [];
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++)
nodes.push({ id: `n${r}${c}`, x: (c - 1) * G, z: (r - 1) * G });
const nid = (r, c) => `n${r}${c}`;
const edges = [];
let eid = 0;
for (let r = 0; r < 3; r++) for (let c = 0; c < 3; c++) {
if (c < 2) edges.push({ id: `e${eid++}`, a: nid(r, c), b: nid(r, c + 1), width: r === 1 ? 9 : 6, kind: r === 1 ? 'main' : 'side' });
if (r < 2) edges.push({ id: `e${eid++}`, a: nid(r, c), b: nid(r + 1, c), width: c === 1 ? 9 : 6, kind: c === 1 ? 'main' : 'side' });
}
const graph = { nodes, edges };
// ground + road/footpath strips so the streets read
const groundMat = new THREE.MeshStandardMaterial({ color: 0x6f7a52, roughness: 1 });
const ground = new THREE.Mesh(new THREE.PlaneGeometry(400, 400), groundMat);
ground.rotation.x = -Math.PI / 2; ground.position.y = -0.02; scene.add(ground);
function addStrip(width, len, cx, cz, beta, color, y) {
const m = new THREE.Mesh(new THREE.PlaneGeometry(width, len),
new THREE.MeshStandardMaterial({ color, roughness: 1 }));
m.rotation.x = -Math.PI / 2; m.rotation.z = -beta; // z-rotation in the flattened plane aligns length
m.position.set(cx, y, cz);
scene.add(m);
return m;
}
const nodeById = id => nodes.find(n => n.id === id);
for (const e of edges) {
const A = nodeById(e.a), B = nodeById(e.b);
const dx = B.x - A.x, dz = B.z - A.z, len = Math.hypot(dx, dz);
const beta = Math.atan2(dx, dz);
const cx = (A.x + B.x) / 2, cz = (A.z + B.z) / 2;
addStrip(e.width, len, cx, cz, beta, 0x2f2c29, 0.0); // asphalt
const foot = e.width / 2 + 0.9, px = Math.cos(beta), pz = -Math.sin(beta);
addStrip(1.8, len, cx + px * foot, cz + pz * foot, beta, 0x8b8375, 0.01); // footpath +side
addStrip(1.8, len, cx - px * foot, cz - pz * foot, beta, 0x8b8375, 0.01); // footpath -side
}
// junction pads
for (const n of nodes) {
const pad = new THREE.Mesh(new THREE.CircleGeometry(5.5, 20),
new THREE.MeshStandardMaterial({ color: 0x323029, roughness: 1 }));
pad.rotation.x = -Math.PI / 2; pad.position.set(n.x, 0.005, n.z); scene.add(pad);
}
// ---------- fleet + sim ----------
const fleet = loadPedFleet(NOASSETS ? 'models/peds/__none__/' : 'models/peds/');
const sim = new CitizenSim({ renderer, scene, camera, citySeed: SEED, graph, fleet });
// ---------- keeper demo (mock counter, since Lane C hasn't landed) ----------
const counter = new THREE.Mesh(new THREE.BoxGeometry(3, 1.05, 1),
new THREE.MeshStandardMaterial({ color: 0x6b4a2e, roughness: 0.8 }));
counter.position.set(6, 0.525, 10); scene.add(counter);
const keepers = new KeeperManager({ camera, citySeed: SEED, fleet });
let keeper = null;
fleet.whenReady.then(() => { keeper = keepers.spawn(scene, { x: 6, z: 11.2, ry: 0, shopId: 'demo', type: 'record' }); });
if (NOASSETS) keeper = keepers.spawn(scene, { x: 6, z: 11.2, ry: 0, shopId: 'demo', type: 'record' });
// ---------- debug tier markers ----------
const TIER_COL = { near: new THREE.Color(0x66dd88), mid: new THREE.Color(0xffcc44), far: new THREE.Color(0x884466) };
const ring = new THREE.RingGeometry(0.4, 0.62, 18); ring.rotateX(-Math.PI / 2);
const markers = new THREE.InstancedMesh(ring,
new THREE.MeshBasicMaterial({ transparent: true, opacity: 0.85 }), 220);
markers.frustumCulled = false; markers.visible = false; markers.count = 0; scene.add(markers);
const _mm = new THREE.Matrix4(), _mp = new THREE.Vector3(), _mq = new THREE.Quaternion(), _ms = new THREE.Vector3(1, 1, 1);
function paintTierMarkers(active) {
let n = 0;
for (let i = 0; i < active && n < 220; i++) {
const c = sim.roster[i];
_mp.set(c.x, 0.06, c.z); _mm.compose(_mp, _mq, _ms);
markers.setMatrixAt(n, _mm); markers.setColorAt(n, TIER_COL[c.tier] || TIER_COL.far); n++;
}
markers.count = n; markers.instanceMatrix.needsUpdate = true;
if (markers.instanceColor) markers.instanceColor.needsUpdate = true;
}
// ---------- minimal orbit camera ----------
const cam = { target: new THREE.Vector3(0, 1, 0), theta: 0.6, phi: 1.15, radius: 55 };
function applyCam() {
const r = cam.radius, sp = Math.sin(cam.phi), cp = Math.cos(cam.phi);
camera.position.set(
cam.target.x + r * sp * Math.sin(cam.theta),
cam.target.y + r * cp,
cam.target.z + r * sp * Math.cos(cam.theta));
camera.lookAt(cam.target);
}
let drag = false, lx = 0, ly = 0;
renderer.domElement.addEventListener('pointerdown', e => { drag = true; lx = e.clientX; ly = e.clientY; });
addEventListener('pointerup', () => drag = false);
addEventListener('pointermove', e => {
if (!drag) return;
cam.theta -= (e.clientX - lx) * 0.005; cam.phi = Math.max(0.15, Math.min(1.5, cam.phi - (e.clientY - ly) * 0.005));
lx = e.clientX; ly = e.clientY;
});
addEventListener('wheel', e => { cam.radius = Math.max(4, Math.min(240, cam.radius * (1 + Math.sign(e.deltaY) * 0.08))); }, { passive: true });
const keys = {};
addEventListener('keydown', e => keys[e.key.toLowerCase()] = true);
addEventListener('keyup', e => keys[e.key.toLowerCase()] = false);
function moveTarget(dt) {
const sp = 26 * dt, fx = Math.sin(cam.theta), fz = Math.cos(cam.theta);
if (keys['w']) { cam.target.x -= fx * sp; cam.target.z -= fz * sp; }
if (keys['s']) { cam.target.x += fx * sp; cam.target.z += fz * sp; }
if (keys['a']) { cam.target.x -= fz * sp; cam.target.z += fx * sp; }
if (keys['d']) { cam.target.x += fz * sp; cam.target.z -= fx * sp; }
if (keys['q']) cam.target.y = Math.max(0.2, cam.target.y - sp * 0.5);
if (keys['e']) cam.target.y += sp * 0.5;
}
// ---------- UI ----------
const $ = id => document.getElementById(id);
let population = 120, debugTiers = false;
sim.setPopulation(population);
$('pop').oninput = e => { population = +e.target.value; $('l-pop').textContent = population; sim.setPopulation(population); };
function fmtTod(t) { const h = Math.floor(t * 24), m = Math.floor((t * 24 % 1) * 60); return `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`; }
$('tod').oninput = e => { const t = +e.target.value / 1000; $('l-tod').textContent = fmtTod(t); sim.setTimeOfDay(t); };
sim.setTimeOfDay(0.5);
$('btn-tiers').onclick = () => {
debugTiers = !debugTiers; sim.setDebugTiers(debugTiers);
markers.visible = debugTiers; $('btn-tiers').classList.toggle('on', debugTiers);
};
$('btn-det').onclick = () => {
const active = sim.stats.active;
const live = sim.identitySignature();
const fresh = [];
for (let i = 0; i < active; i++) {
const idn = identityOf(SEED, edges.length, i);
let pedIndex = -1;
if (fleet.ready) { const pk = pickRig(fleet, idn.pedRoll); if (pk) pedIndex = pk.index; }
fresh.push(signatureOf(i, idn, pedIndex));
}
const ok = live.length === fresh.length && live.every((s, i) => s === fresh[i]);
$('det').style.color = ok ? '#8fd88f' : '#e06a5a';
$('det').textContent = ok
? `✓ deterministic — ${active} identities match seed ${SEED}`
: `✗ MISMATCH at ${live.findIndex((s, i) => s !== fresh[i])}`;
};
if (NOASSETS) $('warn').textContent = '?noassets — placeholder figures only';
// ---------- loop ----------
let last = performance.now(), fpsSmooth = 60;
function frame(now) {
const dt = Math.min(0.05, (now - last) / 1000); last = now;
moveTarget(dt); applyCam();
renderer.info.reset();
const st = sim.update(dt);
keepers.update(dt);
if (debugTiers) paintTierMarkers(st.active);
renderer.render(scene, camera);
const fps = 1 / Math.max(1e-3, dt); fpsSmooth += (fps - fpsSmooth) * 0.1;
$('fps').textContent = fpsSmooth.toFixed(0);
$('fpsbar').style.width = Math.min(100, fpsSmooth / 60 * 100) + '%';
$('s-rig').textContent = st.rigged;
$('s-mid').textContent = st.mid;
$('s-far').textContent = st.far;
$('s-active').textContent = st.active;
$('s-mixer').textContent = st.mixerMs.toFixed(2);
$('s-draws').textContent = renderer.info.render.calls;
$('s-tris').textContent = renderer.info.render.triangles.toLocaleString();
$('s-pool').textContent = st.poolTotal;
$('s-mode').textContent = st.mode + (NOASSETS ? ' (noassets)' : '');
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight; camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
// pause mixers only on a real visibility change (never fires in the preview pane, which is
// permanently "hidden" yet visible to us) — satisfies "all mixers pause when tab hidden".
document.addEventListener('visibilitychange', () => sim.setPaused(document.hidden));
window._sim = sim; // dev hook
// dev harness: drive N sim+render frames deterministically (the preview pane throttles rAF)
window._dbg = { renderer, scene, camera, cam, keepers, applyCam, paintTierMarkers,
step(n = 1, dt = 0.016) { for (let i = 0; i < n; i++) { applyCam(); const st = sim.update(dt); keepers.update(dt); if (debugTiers) paintTierMarkers(st.active); renderer.render(scene, camera); } },
look(tx, ty, tz, radius, theta = cam.theta, phi = cam.phi) { cam.target.set(tx, ty, tz); cam.radius = radius; cam.theta = theta; cam.phi = phi; applyCam(); } };
</script>
</body>
</html>

250
web/js/citizens/impostor.js Normal file
View File

@ -0,0 +1,250 @@
// PROCITY Lane D — impostor atlas + instanced billboard layer (the mid-tier LOD).
//
// Rigged mixers are expensive, so past ~25m a citizen becomes a flat billboard. To keep the mid
// crowd automatically in sync with the fleet, we render each ped ONCE to an offscreen render target
// from 4 yaw angles → one sprite atlas → an InstancedMesh of camera-facing quads that each pick the
// atlas cell for its nearest baked angle. One draw call for the entire mid crowd, per atlas.
//
// Cylindrical billboard (locked upright, rotates around Y toward the camera). No per-frame CPU
// matrix math beyond writing instance translation/scale + the chosen uv-offset.
import * as THREE from 'three';
// ---- bake: subjects[] (each { object3D, height }) × yaws → one atlas texture on a WebGLRenderTarget.
// Returns an atlas descriptor; keep it for the layer, call dispose() when done with the crowd.
export function bakeImpostorAtlas(renderer, subjects, { yaws = 4, cell = 128, maxTex = 2048, environment = null } = {}) {
const n = subjects.length;
const total = n * yaws;
const cols = Math.max(1, Math.min(total, Math.floor(maxTex / cell)));
const rows = Math.ceil(total / cols);
const atlasW = cols * cell, atlasH = rows * cell;
const rt = new THREE.WebGLRenderTarget(atlasW, atlasH, {
minFilter: THREE.LinearFilter, magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat, generateMipmaps: false, depthBuffer: true,
});
// store LINEAR lit colour (no tone-map, no OETF) — the layer shader tone-maps + sRGB-encodes so
// impostors match the ACES-tonemapped rigs exactly (seamless near↔mid). See IMP_FRAG.
rt.texture.colorSpace = THREE.LinearSRGBColorSpace;
const exposure = renderer.toneMappingExposure;
// a tiny bake scene lit to roughly match a warm street key so impostors read like the rigs.
// the environment (PMREM) matters: the peds are metallic-PBR and render dark without one.
const scene = new THREE.Scene();
scene.environment = environment;
const key = new THREE.DirectionalLight(0xfff2e0, 2.1); key.position.set(0.6, 1.4, 0.9); scene.add(key);
scene.add(new THREE.HemisphereLight(0xbfd4ff, 0x50463a, 1.2));
const holder = new THREE.Group(); scene.add(holder);
const cam = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.01, 100);
// preserve renderer state
const prevRT = renderer.getRenderTarget();
const prevAutoClear = renderer.autoClear;
const prevClear = new THREE.Color(); renderer.getClearColor(prevClear);
const prevAlpha = renderer.getClearAlpha();
const prevScissorTest = renderer.getScissorTest();
const prevToneMapping = renderer.toneMapping;
const prevViewport = new THREE.Vector4(); renderer.getViewport(prevViewport);
const prevScissor = new THREE.Vector4(); renderer.getScissor(prevScissor);
renderer.toneMapping = THREE.NoToneMapping; // bake linear; the shader tone-maps at display time
renderer.setRenderTarget(rt);
renderer.setClearColor(0x000000, 0);
renderer.clear(); // one clear to transparent, then paint cells without clearing
renderer.autoClear = false;
renderer.setScissorTest(true);
// square framing (frustum aspect MUST equal the square cell or the figure stretches). The cell
// frames a square world region 1.14h tall, from y=-0.07h (below the feet) to y=1.07h (headroom).
const SPAN = 1.14, FOOT = 0.07;
for (let s = 0; s < n; s++) {
const subj = subjects[s];
const obj = subj.object3D;
const h = subj.height || 1.9;
const half = h * SPAN * 0.5; // square: half-width == half-height
cam.left = -half; cam.right = half;
cam.top = h * (SPAN - FOOT); cam.bottom = -h * FOOT; // total height = h*SPAN, feet at world y=0
cam.position.set(0, h * 0.5, -h * 4); // camera on -Z, looking toward +Z (unit y=0 = feet)
cam.lookAt(0, h * 0.5, 0);
cam.updateProjectionMatrix();
holder.clear();
holder.add(obj);
obj.position.set(0, 0, 0);
for (let y = 0; y < yaws; y++) {
obj.rotation.y = (y / yaws) * Math.PI * 2; // bake evenly-spaced yaws; y=0 faces the camera
obj.updateWorldMatrix(true, true);
const k = s * yaws + y;
const col = k % cols, row = (k / cols) | 0;
// WebGL viewport/scissor origin is bottom-left
const px = col * cell, py = atlasH - (row + 1) * cell;
renderer.setViewport(px, py, cell, cell);
renderer.setScissor(px, py, cell, cell);
renderer.render(scene, cam);
}
holder.remove(obj);
}
// restore renderer
renderer.setScissorTest(prevScissorTest);
renderer.setViewport(prevViewport); // per-cell setViewport clobbered these — restore both
renderer.setScissor(prevScissor);
renderer.autoClear = prevAutoClear;
renderer.setClearColor(prevClear, prevAlpha);
renderer.setRenderTarget(prevRT);
renderer.toneMapping = prevToneMapping;
return {
texture: rt.texture, rt, yaws, cols, rows, n, exposure,
frame: { span: SPAN, foot: FOOT }, // billboard sizing must match the bake framing
cellScale: new THREE.Vector2(1 / cols, 1 / rows),
// uv min corner (top-left in texture space) for a (subject, yaw) cell
cellUV(s, y) {
const k = s * yaws + y;
const col = k % cols, row = (k / cols) | 0;
return [col / cols, 1 - (row + 1) / rows];
},
dispose() { rt.dispose(); },
};
}
// ---- the instanced billboard layer ----
const IMP_VERT = `
attribute vec2 iUvOffset;
uniform vec2 uCellScale;
varying vec2 vUv;
void main() {
vUv = iUvOffset + uv * uCellScale;
// instance translation + non-uniform scale (x = width, y = height) live in instanceMatrix.
// Fold in modelMatrix so impostors track the citizens group's transform (chunk offsets in Lane B),
// exactly like the rig children do — cameraPosition is world space, so the base must be too.
vec3 base = (modelMatrix * vec4(instanceMatrix[3][0], instanceMatrix[3][1], instanceMatrix[3][2], 1.0)).xyz;
float w = length(vec3(instanceMatrix[0][0], instanceMatrix[0][1], instanceMatrix[0][2]));
float h = length(vec3(instanceMatrix[1][0], instanceMatrix[1][1], instanceMatrix[1][2]));
vec3 toCam = cameraPosition - base;
vec3 toCamH = normalize(vec3(toCam.x, 0.0, toCam.z));
vec3 right = normalize(cross(vec3(0.0, 1.0, 0.0), toCamH)); // camera-right, horizontal
vec3 up = vec3(0.0, 1.0, 0.0);
vec3 world = base + right * (position.x * w) + up * (position.y * h);
gl_Position = projectionMatrix * viewMatrix * vec4(world, 1.0);
}
`;
// atlas holds LINEAR lit colour → ACES tone-map (three's fit) → sRGB OETF, so mid impostors match
// the ACES-tonemapped near rigs. Without this the linear texels display ~2× too dark.
const IMP_FRAG = `
uniform sampler2D uAtlas;
uniform vec3 uTint;
uniform float uExposure;
varying vec2 vUv;
// imp-prefixed to avoid colliding with three's injected tonemapping functions of the same name
vec3 impRRTFit(vec3 v){ vec3 a=v*(v+0.0245786)-0.000090537; vec3 b=v*(0.983729*v+0.4329510)+0.238081; return a/b; }
vec3 impACES(vec3 color){
color *= uExposure / 0.6; // three's ACES normalises exposure by 0.6 — match it exactly
const mat3 ACESInput = mat3(0.59719,0.07600,0.02840, 0.35458,0.90834,0.13383, 0.04823,0.01566,0.83777);
const mat3 ACESOutput = mat3(1.60475,-0.10208,-0.00327, -0.53108,1.10813,-0.07276, -0.07367,-0.00605,1.07602);
color = ACESInput * color; color = impRRTFit(color); color = ACESOutput * color;
return clamp(color, 0.0, 1.0);
}
vec3 impLin2sRGB(vec3 c){ return mix(c*12.92, 1.055*pow(c,vec3(1.0/2.4))-0.055, step(0.0031308,c)); }
void main() {
vec4 c = texture2D(uAtlas, vUv);
if (c.a < 0.5) discard; // hard cut-out — no sorting needed
vec3 col = impLin2sRGB(impACES(c.rgb)) * uTint;
gl_FragColor = vec4(col, 1.0);
}
`;
export class ImpostorLayer {
constructor(atlas, { maxInstances = 256 } = {}) {
this.atlas = atlas;
this.max = maxInstances;
// unit quad, origin at bottom-centre (x in [-0.5,0.5], y in [0,1]) so it plants on the ground
const geo = new THREE.PlaneGeometry(1, 1);
geo.translate(0, 0.5, 0);
const iGeo = new THREE.InstancedBufferGeometry();
iGeo.index = geo.index;
iGeo.attributes.position = geo.attributes.position;
iGeo.attributes.uv = geo.attributes.uv;
this._uvOffset = new THREE.InstancedBufferAttribute(new Float32Array(maxInstances * 2), 2);
this._uvOffset.setUsage(THREE.DynamicDrawUsage);
iGeo.setAttribute('iUvOffset', this._uvOffset);
this.material = new THREE.ShaderMaterial({
vertexShader: IMP_VERT, fragmentShader: IMP_FRAG,
uniforms: {
uAtlas: { value: atlas.texture },
uCellScale: { value: atlas.cellScale },
uTint: { value: new THREE.Color(1, 1, 1) },
uExposure: { value: atlas.exposure ?? 1.0 },
},
transparent: false, side: THREE.DoubleSide,
toneMapped: false, // we tone-map in-shader (impACES); stop three injecting its own ACES fns
});
this.mesh = new THREE.InstancedMesh(iGeo, this.material, maxInstances);
this.mesh.frustumCulled = false; // we cull citizens ourselves; billboards span chunks
this.mesh.count = 0;
this.mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage);
this._m = new THREE.Matrix4();
this._q = new THREE.Quaternion();
this._s = new THREE.Vector3();
this._p = new THREE.Vector3();
}
// pick the baked yaw whose view best matches how the camera sees this citizen's facing.
// facing = the citizen's heading angle (atan2(-dx,-dz) convention, matches rig fig.rotation.y).
_yawIndex(x, z, facing, camX, camZ) {
// ped forward (world) for heading `facing`: (-sin, -cos)
const fx = -Math.sin(facing), fz = -Math.cos(facing);
// ped→camera (horizontal)
let cx = camX - x, cz = camZ - z;
const len = Math.hypot(cx, cz) || 1; cx /= len; cz /= len;
// signed angle from forward to ped→cam, about +Y
const dot = fx * cx + fz * cz;
const cross = fx * cz - fz * cx; // y-component of (forward × toCam)
let ang = Math.atan2(cross, dot); // -π..π ; 0 = camera in front of ped
const step = (Math.PI * 2) / this.atlas.yaws;
let idx = Math.round(ang / step);
idx = ((idx % this.atlas.yaws) + this.atlas.yaws) % this.atlas.yaws;
return idx;
}
// list: [{ x, z, groundY, height, subject, facing }] — subject = atlas subject index (ped type)
update(list, camera) {
const camX = camera.position.x, camZ = camera.position.z;
const span = this.atlas.frame.span, foot = this.atlas.frame.foot;
const count = Math.min(list.length, this.max);
const off = this._uvOffset.array;
for (let i = 0; i < count; i++) {
const it = list[i];
const h = it.height || 1.75;
const cell = h * span; // square billboard, matches the baked cell
// instanceMatrix carries translation (feet-minus-margin) + scale (width in x, height in y)
this._p.set(it.x, (it.groundY || 0) - h * foot, it.z);
this._q.identity();
this._s.set(cell, cell, 1);
this._m.compose(this._p, this._q, this._s);
this.mesh.setMatrixAt(i, this._m);
const yaw = this._yawIndex(it.x, it.z, it.facing || 0, camX, camZ);
const [u, v] = this.atlas.cellUV(it.subject % this.atlas.n, yaw);
off[i * 2] = u; off[i * 2 + 1] = v;
}
this.mesh.count = count;
this.mesh.instanceMatrix.needsUpdate = true;
this._uvOffset.needsUpdate = true;
}
setTint(hex) { this.material.uniforms.uTint.value.set(hex); }
// keep impostors matched to the rigs if the shell animates renderer.toneMappingExposure (day/night)
setExposure(e) { this.material.uniforms.uExposure.value = e; }
dispose() {
this.mesh.geometry.dispose();
this.material.dispose();
this.atlas.dispose();
}
}

View File

@ -0,0 +1,86 @@
// PROCITY Lane D — shopkeepers. One keeper per open shop, standing at the counter slot, playing a
// shared idle clip, turning to greet the player when they come near. Keepers are spawned when an
// interior builds and disposed with it (they don't share the street's rig pool — a handful at a time,
// each owns its actor).
//
// Coordination (Lane F wiring): Lane C exposes the counter position + facing in its interior `places`;
// pass it to spawn(). Until Lane C lands, the test page mocks a counter. Deterministic per shop:
// rng(citySeed,'keeper',shopId) picks the ped type + height so a shop's keeper is stable across visits.
import * as THREE from 'three';
import { rng } from '../core/prng.js';
import { pickRig, spawnRig } from './rigs.js';
import { makePlaceholder } from './placeholder.js';
const GREET_RANGE = 6.0; // m — start turning to the player inside this
const GREET_CLAMP = 0.62; // rad — max turn off the counter-facing base (~35°)
const GREET_EASE = 3.5; // turn responsiveness
export class KeeperManager {
constructor({ camera = null, citySeed = 20261990, fleet = null } = {}) {
this.camera = camera;
this.citySeed = citySeed >>> 0;
this.fleet = fleet;
this.keepers = [];
this._p = new THREE.Vector3();
}
// spawn(target, { x, z, ry, shopId, type }) → keeper handle. `ry` = the counter's outward facing.
spawn(target, { x = 0, z = 0, ry = 0, shopId = 'shop', type = 'shop' } = {}) {
const r = rng(this.citySeed, 'keeper', shopId);
const height = 1.58 + r() * 0.34;
let actor, kind;
if (this.fleet && this.fleet.ready) {
const pk = pickRig(this.fleet, r());
const rig = pk && this.fleet.all[pk.index];
const spawned = rig && spawnRig(rig, { ry, height, clip: this.fleet.idleClip, phase: r() });
if (spawned) { actor = spawned; kind = 'rig'; }
}
if (!actor) { // asset-free fallback
const ph = makePlaceholder(rng(this.citySeed, 'keeper-body', shopId), { height });
ph.fig.rotation.y = ry;
actor = ph; kind = 'placeholder';
}
actor.fig.position.set(x, 0, z);
target.add(actor.fig);
const k = { actor, kind, baseRy: ry, curTurn: 0, target, shopId, type };
this.keepers.push(k);
return k;
}
remove(k) {
if (!k) return;
const i = this.keepers.indexOf(k);
if (i >= 0) this.keepers.splice(i, 1);
k.target.remove(k.actor.fig);
k.actor.dispose?.();
}
disposeAll() { [...this.keepers].forEach(k => this.remove(k)); }
// update(dt, playerPos?) — tick idle animation + the greet head/body turn. playerPos defaults to
// the camera position (the player IS the camera in interior mode).
update(dt, playerPos = null) {
const pp = playerPos || (this.camera && this.camera.position) || null;
for (const k of this.keepers) {
const a = k.actor;
if (a.mixer) a.mixer.update(dt); // rig idle
else a.tick?.(dt, false); // placeholder idle
if (pp) {
const dx = pp.x - a.fig.position.x, dz = pp.z - a.fig.position.z;
const dist = Math.hypot(dx, dz);
let want = 0;
if (dist < GREET_RANGE) {
// desired absolute facing toward the player, expressed relative to the counter base
const toPlayer = Math.atan2(-dx, -dz); // rig-front convention (local -Z)
let rel = toPlayer - k.baseRy;
rel = Math.atan2(Math.sin(rel), Math.cos(rel)); // wrap to -π..π
want = Math.max(-GREET_CLAMP, Math.min(GREET_CLAMP, rel));
}
k.curTurn += (want - k.curTurn) * Math.min(1, dt * GREET_EASE);
a.fig.rotation.y = k.baseRy + k.curTurn;
}
}
}
}

View File

@ -0,0 +1,127 @@
// PROCITY Lane D — placeholder figures.
// Low-poly chunky humanoids built from boxes (the POLY style lock), so the town is populated
// from frame one and stays populated in ?noassets mode. A placeholder hot-swaps to a real rig
// when the ped fleet finishes loading (upgradeStreetPeople pattern) — but on its own it walks,
// idles, and bakes into impostors just like a rig. Feet planted at local y=0, head at +height.
//
// Deterministic: every colour + proportion is drawn from a seeded stream (core/prng), so the
// same citizen id yields the same body twice. No Math.random() here.
import * as THREE from 'three';
import { frange, pick } from '../core/prng.js';
// muted 90s-Australian palette (shirts / pants / skin) — reads well under warm cinematic light
const SHIRTS = ['#8d9db6', '#b6564e', '#d8b23a', '#5f7a52', '#c8794a', '#7a5c8d', '#c9c2b0',
'#3f6b6b', '#a94f6b', '#4d6a94', '#d0d0c8', '#8a6d3b'];
const PANTS = ['#3a3f4a', '#4a3b2e', '#2e3d33', '#5a5148', '#38414d', '#6b5b4a', '#2f2f38'];
const SKINS = ['#e6c0a0', '#d8a878', '#c68a5e', '#a8683e', '#8a5230', '#f0d4b8'];
const HAIRS = ['#2a221c', '#4a3526', '#6b4a2e', '#111013', '#8a7a5a', '#b0a090', '#7a2e1e'];
let _sharedGeo = null; // one box geometry shared across every placeholder part (cheap)
function geo() { return (_sharedGeo ||= new THREE.BoxGeometry(1, 1, 1)); }
function part(mat, w, h, d, x, y, z) {
const m = new THREE.Mesh(geo(), mat);
m.scale.set(w, h, d);
m.position.set(x, y, z);
m.castShadow = false;
m.frustumCulled = false;
return m;
}
// makePlaceholder(r, { height }) → { fig, tick(dt, moving), dispose }
// r : a seeded mulberry32 stream (rng(citySeed,'citizen',id))
// height : target standing height in metres (feet at y=0)
// The figure exposes two hip pivots + two shoulder pivots that swing for a chunky walk/idle.
export function makePlaceholder(r, { height = 1.75 } = {}) {
const H = height;
const skin = pick(r, SKINS), shirt = pick(r, SHIRTS), pant = pick(r, PANTS), hair = pick(r, HAIRS);
const mSkin = new THREE.MeshStandardMaterial({ color: new THREE.Color(skin), roughness: 0.9 });
const mShirt = new THREE.MeshStandardMaterial({ color: new THREE.Color(shirt), roughness: 0.85 });
const mPant = new THREE.MeshStandardMaterial({ color: new THREE.Color(pant), roughness: 0.85 });
const mHair = new THREE.MeshStandardMaterial({ color: new THREE.Color(hair), roughness: 0.95 });
// proportions (fractions of H), lightly jittered per person so the crowd isn't a clone army
const build = frange(r, 0.9, 1.12); // slim ↔ stocky
const legH = H * 0.46;
const torsoH = H * 0.30;
const headH = H * 0.13;
const shoulderW = H * (0.16 * build);
const hipW = H * (0.11 * build);
const fig = new THREE.Group();
// legs — pivot at hip so they swing from the top
const legLen = legH;
const mkLeg = (side) => {
const pivot = new THREE.Group();
pivot.position.set(side * hipW * 0.5, legLen, 0); // hip height
const leg = part(mPant, hipW * 0.9, legLen, hipW * 0.9, 0, -legLen * 0.5, 0);
// little foot for grounding read
// toes point -Z so the placeholder "faces" -Z like the GLB rigs (uniform facing math)
const foot = part(mPant, hipW * 0.95, legLen * 0.12, hipW * 1.6, 0, -legLen + legLen * 0.06, -hipW * 0.4);
pivot.add(leg, foot);
fig.add(pivot);
return pivot;
};
const legL = mkLeg(-1), legR = mkLeg(1);
// torso
const torsoY = legH + torsoH * 0.5;
fig.add(part(mShirt, shoulderW, torsoH, shoulderW * 0.55, 0, torsoY, 0));
// arms — pivot at shoulder
const armLen = torsoH * 1.02;
const mkArm = (side) => {
const pivot = new THREE.Group();
pivot.position.set(side * shoulderW * 0.5, legH + torsoH * 0.92, 0);
const arm = part(mShirt, shoulderW * 0.24, armLen, shoulderW * 0.24, 0, -armLen * 0.5, 0);
const hand = part(mSkin, shoulderW * 0.26, armLen * 0.14, shoulderW * 0.26, 0, -armLen + armLen * 0.07, 0);
pivot.add(arm, hand);
fig.add(pivot);
return pivot;
};
const armL = mkArm(-1), armR = mkArm(1);
// head + hair (a "head" node so the sim can do the keeper greet head-turn on placeholders too)
const headPivot = new THREE.Group();
headPivot.name = 'head';
headPivot.position.set(0, legH + torsoH, 0);
const head = part(mSkin, headH * 0.85, headH, headH * 0.85, 0, headH * 0.5, 0);
const cap = part(mHair, headH * 0.92, headH * 0.4, headH * 0.92, 0, headH * 0.9, 0);
headPivot.add(head, cap);
fig.add(headPivot);
fig.userData.isPlaceholder = true;
// gait state
let phase = frange(r, 0, Math.PI * 2); // desync
const swing = 0.9; // radians of leg swing when walking
let breathe = frange(r, 0, Math.PI * 2);
function tick(dt, moving) {
if (moving) {
phase += dt * 7.5; // stride cadence
const s = Math.sin(phase) * swing;
legL.rotation.x = s;
legR.rotation.x = -s;
armL.rotation.x = -s * 0.7;
armR.rotation.x = s * 0.7;
} else {
// idle: settle limbs, gentle breathing sway
breathe += dt * 1.6;
const ease = 0.86;
legL.rotation.x *= ease; legR.rotation.x *= ease;
const b = Math.sin(breathe) * 0.04;
armL.rotation.x = armL.rotation.x * ease - b;
armR.rotation.x = armR.rotation.x * ease - b;
headPivot.rotation.z = Math.sin(breathe * 0.5) * 0.02;
}
}
function dispose() {
fig.traverse(o => { if (o.isMesh && o.material) o.material.dispose(); });
}
return { fig, tick, dispose, head: headPivot };
}

202
web/js/citizens/rigs.js Normal file
View File

@ -0,0 +1,202 @@
// PROCITY Lane D — the rig stack. Ported faithfully from 90sDJsim web/world/index.html ~405500
// (loadRig / spawnRig / _canon / canonRig / _rotOnly / head-bone height-normalize / the shared
// walk+idle clip driving all 19 peds). This stack is proven; the changes for PROCITY are:
// • loaders come from core/loaders.js (promise-cached, fail-soft) instead of an inline one
// • randomness is injected (a seeded stream) — no Math.random() in identity/placement
// • the figure is wrapped in an outer Group whose origin sits at the feet (ground = local y=0),
// so the sim can move/rotate a walker without re-planting it every frame.
//
// House law: one canonical clip bank drives every character (shared skeleton namespace), never a
// per-character rig. SkeletonUtils.clone gives each spawn its own skeleton off a shared base mesh.
import * as THREE from 'three';
import { clone as skeletonClone } from 'three/addons/utils/SkeletonUtils.js';
import { loadGLB } from '../core/loaders.js';
// The 19 rigged peds (17 normal + 2 comical) copied byte-identical from 90sDJsim into web/models/peds/.
export const PED_NAMES = {
normal: ['man_worker_hivis_01', 'man_suit_01', 'man_casual_01', 'man_casual_02', 'man_casual_03',
'man_grunge_01', 'man_elder_01', 'man_athletic_01', 'man_bouncer_01', 'man_soldier_ww2_01',
'man_dj_streetwear_01', 'woman_casual_01', 'woman_casual_02', 'woman_business_01',
'woman_athleisure_01', 'woman_smart_01', 'woman_chef_01'],
comical: ['comical_luchador_01', 'comical_boy_01'],
};
// ---- Mixamo skeleton canonicalisation (the crown-jewel trick) ----
// mixamorig1Hips vs mixamorig4Hips → mixamorig Hips, so ANY clip binds to ANY character.
const _canon = s => s.replace(/mixamorig\d+/g, 'mixamorig');
function canonRig(r) {
if (!r) return r;
if (r.scene) r.scene.traverse(o => { o.name = _canon(o.name); });
if (r.anims) r.anims.forEach(a => a.tracks.forEach(t => { t.name = _canon(t.name); }));
return r;
}
// shared-clip filter: keep limb/spine rotations only — drop ALL position tracks (a different-scale
// source inflates/crumples the target) AND Hips.quaternion (a different-orientation source lays it
// flat). The character keeps its own upright bind root; the game translates walkers itself.
const _rotOnly = c => new THREE.AnimationClip(c.name, c.duration,
c.tracks.filter(t => t.name.endsWith('.quaternion') && !/Hips\.quaternion$/i.test(t.name)));
// core loadGLB returns the whole gltf (scene + animations); adapt to the {scene, anims} rig shape.
function loadRig(ref) {
return loadGLB(ref).then(g => (g ? { scene: g.scene, anims: g.animations || [] } : null));
}
// loadPedFleet(base) → a live Fleet object, returned immediately (ready:false). Arrays fill in as
// GLBs land; fleet.whenReady resolves when every load settled. The town runs on placeholders until
// then, then upgradeStreetPeople swaps them (see sim.js). Missing files just leave the fleet smaller.
export function loadPedFleet(base = 'models/peds/') {
const fleet = {
normal: [], comical: [], all: [], // all = normal ++ comical, stable index for the impostor atlas
walkClip: null, idleClip: null,
ready: false, whenReady: null,
};
// DETERMINISM: fill fixed slots by PED_NAMES index, NOT push-on-resolve — otherwise the array
// order (and thus every pickRig index + identity signature) depends on GLB load-completion timing,
// which varies per run/machine and silently breaks "same seed → same crowd". Compact after all
// settle (filter(Boolean) preserves the fixed relative order, so a missing file is deterministic too).
const nSlots = new Array(PED_NAMES.normal.length).fill(null);
const cSlots = new Array(PED_NAMES.comical.length).fill(null);
const jobs = [];
PED_NAMES.normal.forEach((n, i) => jobs.push(loadRig(`${base}${n}.glb`).then(r => {
if (r) { r.pedName = n; nSlots[i] = canonRig(r); } })));
PED_NAMES.comical.forEach((n, i) => jobs.push(loadRig(`${base}${n}.glb`).then(r => {
if (r) { r.pedName = n; cSlots[i] = canonRig(r); } })));
// shared clips: canonicalise track names, keep rotation only
jobs.push(loadRig(`${base}walk.glb`).then(r => {
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.walkClip = _rotOnly(c); } }));
jobs.push(loadRig(`${base}idle.glb`).then(r => {
const c = r && r.anims && r.anims[0];
if (c) { c.tracks.forEach(t => t.name = _canon(t.name)); fleet.idleClip = _rotOnly(c); } }));
fleet.whenReady = Promise.all(jobs).then(() => {
fleet.normal = nSlots.filter(Boolean);
fleet.comical = cSlots.filter(Boolean);
fleet.all = [...fleet.normal, ...fleet.comical];
fleet.ready = fleet.all.length > 0 && !!fleet.walkClip;
return fleet;
});
return fleet;
}
// deterministic ped choice: ~8% comical curveball, else a normal, indexed by a seeded stream.
// Returns { rig, index } — index is into fleet.all (stable per ped type → impostor atlas cell).
export function pickRig(fleet, r01) {
const comical = r01 < 0.08 && fleet.comical.length ? fleet.comical : null;
const pool = comical || fleet.normal;
if (!pool.length) return null;
const rig = pool[(r01 * 997 % pool.length) | 0];
const index = fleet.all.indexOf(rig);
return { rig, index: index < 0 ? 0 : index };
}
// ---- buildFigure: clone a rig, height-normalise off the head bone, plant feet ----
// Returns { fig, inner, head }. `fig` is an outer Group whose origin is at the feet (ground = y=0)
// — set fig.position to the ground point and fig.rotation.y to the heading. NOTE materials are
// shared across SkeletonUtils clones (cheap); don't mutate them per-instance.
const _wp = new THREE.Vector3();
const _bb = new THREE.Box3();
const _bs = new THREE.Vector3();
export function buildFigure(rig, height = 1.75) {
const inner = skeletonClone(rig.scene);
inner.traverse(o => { if (o.isMesh) { o.frustumCulled = false; o.castShadow = false; } });
const fig = new THREE.Group();
fig.add(inner);
fig.updateWorldMatrix(true, true);
// height-normalise off the head bone (Mixamo export scale is unreliable), then plant feet.
let headY = 0, minY = Infinity;
inner.traverse(o => {
if (o.isBone) {
o.getWorldPosition(_wp);
if (/head/i.test(o.name)) headY = Math.max(headY, _wp.y);
minY = Math.min(minY, _wp.y);
}
});
if (headY > 1e-4) {
inner.scale.setScalar(height / headY);
} else {
// no head bone found — fall back to bounding-box height so we never ship a giant/ant
_bb.setFromObject(inner); _bb.getSize(_bs);
if (_bs.y > 1e-4) inner.scale.setScalar(height / _bs.y);
}
fig.updateWorldMatrix(true, true);
minY = Infinity;
inner.traverse(o => { if (o.isBone) { o.getWorldPosition(_wp); minY = Math.min(minY, _wp.y); } });
if (minY === Infinity) { _bb.setFromObject(inner); minY = _bb.min.y; }
inner.position.y = -minY; // feet at outer y=0
let head = null;
inner.traverse(o => { if (!head && o.isBone && /head/i.test(o.name)) head = o; });
return { fig, inner, head, nominalHeight: height };
}
// bind a clip to an inner figure, keeping only tracks whose target bones exist (else three spams
// "No target node found" per missing bone). Returns the AnimationAction (not yet played).
function _action(mixer, inner, clip) {
if (!clip) return null;
const nodes = new Set();
inner.traverse(o => { if (o.name) nodes.add(o.name); });
const bindable = clip.tracks.filter(t => nodes.has(t.name.split('.')[0]));
const useClip = bindable.length === clip.tracks.length
? clip : new THREE.AnimationClip(clip.name, clip.duration, bindable);
return mixer.clipAction(useClip);
}
// Dispose only what a SkeletonUtils clone actually OWNS: its Skeleton's per-instance bone texture.
// Geometry + materials are SHARED by reference across every clone and the fleet base rig — disposing
// them here would tear down GPU resources still in use by all sibling citizens of the same ped type.
function _disposeInner(inner) {
const skels = new Set();
inner.traverse(o => { if (o.isSkinnedMesh && o.skeleton) skels.add(o.skeleton); });
skels.forEach(s => s.dispose?.());
}
// ---- spawnRig: single-clip figure (keepers, impostor baking). Height baked in. ----
export function spawnRig(rig, { ry = 0, clip = null, height = 1.75, phase = 0 } = {}) {
if (!rig || !rig.scene) return null;
const { fig, inner, head } = buildFigure(rig, height);
fig.rotation.y = ry;
const mixer = new THREE.AnimationMixer(inner);
const chosen = clip || rig.anims.find(c => /idle/i.test(c.name)) || rig.anims[1] || rig.anims[0];
const act = _action(mixer, inner, chosen);
if (act) { act.time = phase * (act.getClip().duration || 0); act.play(); }
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
return { fig, inner, mixer, height, head, dispose };
}
// ---- makeActor: near-tier pedestrian with walk↔idle crossfade (built at nominal height; the sim
// scales the outer Group per-citizen so pooled actors are height-agnostic and reusable). ----
export function makeActor(rig, { walkClip, idleClip, nominalHeight = 1.75 } = {}) {
if (!rig || !rig.scene) return null;
const { fig, inner, head, nominalHeight: nom } = buildFigure(rig, nominalHeight);
const mixer = new THREE.AnimationMixer(inner);
const walkA = _action(mixer, inner, walkClip || rig.anims.find(c => /walk/i.test(c.name)));
const idleA = _action(mixer, inner, idleClip || rig.anims.find(c => /idle/i.test(c.name)));
let moving = null; // tri-state so the first setMoving always applies
// both actions play; exactly one holds weight 1 at rest, setMoving transfers between them
if (walkA) { walkA.play(); walkA.setEffectiveWeight(1); }
if (idleA) { idleA.play(); idleA.setEffectiveWeight(0); }
// walk↔idle: instant on the first apply / fade<=0 (fresh spawn must be posted immediately),
// otherwise a real crossFade transfers weight from the resting action to the target.
function setMoving(m, fade = 0.28) {
if (m === moving) return;
const first = moving === null;
moving = m;
if (!(walkA && idleA)) return;
const to = m ? walkA : idleA, from = m ? idleA : walkA;
to.enabled = from.enabled = true;
if (first || fade <= 0) { to.setEffectiveWeight(1); from.setEffectiveWeight(0); }
else from.crossFadeTo(to, fade, false); // from holds weight 1 at rest → smooth transfer
}
// seed the clip phase so the crowd isn't in lockstep
function setPhase(p) {
if (walkA) walkA.time = p * (walkA.getClip().duration || 1);
if (idleA) idleA.time = p * (idleA.getClip().duration || 1);
}
function dispose() { mixer.stopAllAction(); mixer.uncacheRoot(inner); _disposeInner(inner); }
return { fig, inner, mixer, head, nominalHeight: nom, setMoving, setPhase, dispose,
hasClips: !!(walkA && idleA) };
}

407
web/js/citizens/sim.js Normal file
View File

@ -0,0 +1,407 @@
// PROCITY Lane D — the citizen simulation (Vuntra's layered LOD idea, at hamlet scale).
//
// Deterministic per-citizen identity (same seed → same person walks the same beat), walkers pathing
// along footpath lanes offset from the street graph, three distance tiers:
// • NEAR (<25m): full rig + AnimationMixer (or a placeholder before the fleet loads), walking the
// footpath, turning at nodes on a seeded choice, loitering at points of interest.
// • MID (2570m): no mixers ticking — an instanced billboard impostor (impostor.js).
// • FAR (>70m): culled entirely.
// Promotion/demotion has hysteresis + a hard near-cap so nobody pops and the frame budget holds.
//
// Built on the house fleet rule: shared base meshes (a small rig pool, reused across citizens of the
// same ped type) + one canonical clip bank. Never a per-character rig.
import * as THREE from 'three';
import { rng, seedFor } from '../core/prng.js';
import { pickRig, makeActor } from './rigs.js';
import { makePlaceholder } from './placeholder.js';
import { bakeImpostorAtlas, ImpostorLayer } from './impostor.js';
// ---- tuning (CITY_SPEC budgets) ----
const NEAR_ENTER = 24, NEAR_EXIT = 27; // m, hysteresis band for rig↔impostor
const MID_ENTER = 68, MID_EXIT = 72; // m, hysteresis band for impostor↔culled
const NEAR_MAX = 24; // ≤24 rigged actives (CITY_SPEC)
const RIG_CAP = 30; // pooled rig instances (headroom above NEAR_MAX)
const NEW_RIG_PER_FRAME = 3; // cap actor creation to avoid clone hitches
const MIXER_ALWAYS = 8; // nearest 8 mixers update every frame
const MIXER_EXTRA = 4; // ≤4 more mixers per frame (staggered), CITY_SPEC
const NEAR_BIAS = 2.0; // currently-near citizens keep priority for a rig slot
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
// 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.
const DAY_CURVE = [0.06, 0.10, 0.35, 0.85, 1.0, 0.75, 0.45, 0.18]; // 00,03,06,09,12,15,18,21h
function densityAt(t01) {
const x = ((t01 % 1) + 1) % 1 * DAY_CURVE.length;
const i = Math.floor(x), f = x - i;
return DAY_CURVE[i % DAY_CURVE.length] * (1 - f) + DAY_CURVE[(i + 1) % DAY_CURVE.length] * f;
}
const tierColor = { near: 0x66dd88, mid: 0xffcc44, far: 0x884466 };
// ---- pure deterministic identity (no THREE, no GPU) — same (seed, id) → same person, forever ----
// Kept free-standing so the determinism check can recompute it independently of the live sim.
export function identityOf(citySeed, edgeCount, id) {
const r = rng(citySeed, 'citizen', id);
const pedRoll = r();
const height = 1.55 + r() * 0.40; // 1.551.95 m (CITY_SPEC height range)
const speed = 1.05 + r() * 0.75; // m/s
const edge = (r() * edgeCount) | 0;
const forward = r() < 0.5 ? 1 : -1;
const sFrac = r(); // 0..1 along the edge
const loiterTend = r();
const phase = r();
const pvar = (pedRoll * PLACEHOLDER_VARIANTS) | 0;
return { pedRoll, height, speed, edge, forward, sFrac, loiterTend, phase, pvar };
}
// stable signature string for one citizen (pedIndex assigned later, once the fleet is known)
export function signatureOf(id, idn, pedIndex) {
return `${id}:${pedIndex}:${idn.pvar}:${idn.height.toFixed(3)}:${idn.speed.toFixed(3)}:${idn.edge}:${idn.forward}`;
}
// ---- a small pool of rig actors, reused across citizens of the same ped type ----
class RigPool {
constructor(fleet, clips) { this.fleet = fleet; this.clips = clips; this.free = new Map(); this.total = 0; }
acquire(pedIndex) {
const list = this.free.get(pedIndex);
if (list && list.length) return list.pop();
if (this.total >= RIG_CAP) { // evict one free actor of another type
for (const [k, arr] of this.free) { if (arr.length) { arr.pop().dispose(); this.total--; break; } }
if (this.total >= RIG_CAP) return null; // everything in use — caller falls back to impostor
}
const rig = this.fleet.all[pedIndex];
if (!rig) return null;
const actor = makeActor(rig, this.clips);
if (actor) { actor.pedIndex = pedIndex; this.total++; }
return actor;
}
release(actor) {
if (!actor) return;
let arr = this.free.get(actor.pedIndex);
if (!arr) this.free.set(actor.pedIndex, arr = []);
arr.push(actor);
}
dispose() { for (const arr of this.free.values()) arr.forEach(a => a.dispose()); this.free.clear(); this.total = 0; }
}
export class CitizenSim {
constructor({ renderer, scene, camera, citySeed = 20261990, graph, fleet, group = null }) {
this.renderer = renderer;
this.scene = scene;
this.camera = camera;
this.citySeed = citySeed >>> 0;
this.fleet = fleet;
this.group = group || new THREE.Group();
this.group.name = 'citizens';
scene.add(this.group);
this._setGraph(graph);
this.roster = []; // all generated citizens (identity + live state), indexed by id
this.target = 0; // slider population
this.timeOfDay = 0.5; // noon
this.debugTiers = false;
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._mixerCursor = 0; // round-robin cursor for the staggered mixer budget
this._nearList = []; // reused scratch
this._midList = [];
this._frustum = new THREE.Frustum();
this._pv = new THREE.Matrix4();
this.stats = { active: 0, rigged: 0, mid: 0, far: 0, mixerMs: 0, poolTotal: 0, mode: this.mode };
// bake the placeholder impostor atlas up front so the mid tier works from frame one
this._buildPlaceholderImpostors();
// when the fleet lands, upgrade: bake rig atlas, swap actors, assign real ped types
if (fleet && fleet.whenReady) fleet.whenReady.then(() => { if (fleet.ready) this._upgradeToRigs(); });
}
// ---- graph → footpath lanes ----
_setGraph(graph) {
this.nodes = graph.nodes;
this.edges = graph.edges.map(e => {
const A = graph.nodes.find(n => n.id === e.a), B = graph.nodes.find(n => n.id === e.b);
const dx = B.x - A.x, dz = B.z - A.z, len = Math.hypot(dx, dz) || 1e-3;
const ux = dx / len, uz = dz / len;
const off = (e.width || 4) * 0.5 + FOOTPATH_MARGIN;
return { ...e, A, B, ux, uz, len, off };
});
// adjacency: node id → incident edge indices
this.adj = new Map();
this.nodes.forEach(n => this.adj.set(n.id, []));
this.edges.forEach((e, i) => { this.adj.get(e.a).push(i); this.adj.get(e.b).push(i); });
}
// ---- deterministic identity ----
_makeCitizen(id) {
const idn = identityOf(this.citySeed, this.edges.length, id);
const c = {
id, pedRoll: idn.pedRoll, height: idn.height, speed: idn.speed,
loiterTend: idn.loiterTend, phase: idn.phase, pvar: idn.pvar,
pedIndex: -1, subject: idn.pvar,
edge0: idn.edge, forward0: idn.forward, // immutable spawn beat (for the determinism signature)
// live state (mutates as they walk + turn at nodes)
edge: idn.edge, forward: idn.forward, s: idn.sFrac * this.edges[idn.edge].len, loiter: 0,
x: 0, z: 0, facing: 0,
tier: 'far', actor: null, actorKind: null,
_acc: 0,
turn: rng(this.citySeed, 'turn', id),
loit: rng(this.citySeed, 'loiter', id),
};
// assign a real ped type if the fleet is already up (roster can grow after upgrade)
if (this.mode === 'rig' && this.fleet.ready) {
const pk = pickRig(this.fleet, c.pedRoll);
if (pk) { c.pedIndex = pk.index; c.subject = pk.index; }
}
this._placeOnLane(c);
return c;
}
_ensureRoster(n) {
while (this.roster.length < n) this.roster.push(this._makeCitizen(this.roster.length));
}
// ---- public controls ----
setPopulation(n) { this.target = Math.max(0, n | 0); this._ensureRoster(this.target); }
setTimeOfDay(t01) { this.timeOfDay = t01; }
setDebugTiers(on) {
this.debugTiers = !!on;
if (this.impostor) this.impostor.setTint(on ? tierColor.mid : 0xffffff);
}
// stable identity signature of the active set — immutable spawn identity, NOT live position, so it
// holds while citizens walk (the determinism gate: same seed → same crowd, twice).
identitySignature() {
return this.roster.slice(0, this.stats.active).map(c =>
signatureOf(c.id, { pvar: c.pvar, height: c.height, speed: c.speed, edge: c.edge0, forward: c.forward0 }, c.pedIndex)
);
}
// ---- impostor atlases ----
_buildPlaceholderImpostors() {
const subjects = [];
for (let v = 0; v < PLACEHOLDER_VARIANTS; v++) {
const ph = makePlaceholder(rng(this.citySeed, 'pvar', v), { height: 1.9 });
subjects.push({ object3D: ph.fig, height: 1.9 });
}
const atlas = bakeImpostorAtlas(this.renderer, subjects,
{ yaws: 4, cell: 128, environment: this.scene.environment });
subjects.forEach(s => s.object3D.traverse(o => { if (o.isMesh) o.material.dispose?.(); }));
this._setImpostorLayer(atlas);
}
_upgradeToRigs() {
// bake one atlas cell-set per ped type, from a representative mid-stride pose
const temps = [], subjects = [];
for (let i = 0; i < this.fleet.all.length; i++) {
const rig = this.fleet.all[i];
const spawned = makeActor(rig, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip, nominalHeight: 1.9 });
if (!spawned) continue;
spawned.setMoving(true, 0);
spawned.mixer.update(0.35 + i * 0.017); // desynced mid-stride so the atlas isn't all one pose
temps.push(spawned);
subjects.push({ object3D: spawned.fig, height: 1.9, pedIndex: i });
}
if (!subjects.length) return;
const atlas = bakeImpostorAtlas(this.renderer, subjects,
{ yaws: 4, cell: 128, environment: this.scene.environment });
temps.forEach(t => t.dispose());
this._setImpostorLayer(atlas);
this.mode = 'rig';
this.rigPool = new RigPool(this.fleet, { walkClip: this.fleet.walkClip, idleClip: this.fleet.idleClip });
// assign real ped types to every citizen; drop placeholder near-actors so they re-acquire as rigs
this.roster.forEach(c => {
const pk = pickRig(this.fleet, c.pedRoll);
if (pk) { c.pedIndex = pk.index; c.subject = pk.index; }
if (c.actor && c.actorKind === 'placeholder') { this._releaseActor(c); }
});
}
_setImpostorLayer(atlas) {
if (this.impostor) { this.group.remove(this.impostor.mesh); this.impostor.dispose(); }
this.impostor = new ImpostorLayer(atlas, { maxInstances: IMPOSTOR_MAX });
this.group.add(this.impostor.mesh);
if (this.debugTiers) this.impostor.setTint(tierColor.mid);
}
// ---- lane math ----
_placeOnLane(c) {
const e = this.edges[c.edge];
const start = c.forward > 0 ? e.A : e.B;
const tdx = c.forward * e.ux, tdz = c.forward * e.uz; // travel direction
// right-perpendicular (dz,-dx): opposing walkers take opposite footpaths
const px = tdz, pz = -tdx;
c.x = start.x + tdx * c.s + px * e.off;
c.z = start.z + tdz * c.s + pz * e.off;
c.facing = Math.atan2(-tdx, -tdz); // rig front = local -Z
}
_advance(c, dt) {
if (c.loiter > 0) { c.loiter -= dt; return; }
const e = this.edges[c.edge];
c.s += c.speed * dt;
if (c.s >= e.len) {
// arrived at the far node — pick the next edge (seeded), maybe loiter
const node = c.forward > 0 ? e.b : e.a;
const inc = this.adj.get(node);
let choices = inc.filter(i => i !== c.edge);
if (!choices.length) choices = inc; // dead-end → U-turn
const next = choices[(c.turn() * choices.length) | 0];
const ne = this.edges[next];
c.edge = next;
c.forward = ne.a === node ? 1 : -1;
c.s = Math.min(c.s - e.len, ne.len); // carry leftover distance
if (c.loit() < 0.10 + c.loiterTend * 0.28) c.loiter = 1.4 + c.loit() * 3.2; // window-shop stop
}
this._placeOnLane(c);
}
// ---- actor lifecycle ----
_acquireActor(c) {
if (this.mode === 'rig' && this.rigPool && c.pedIndex >= 0) {
const a = this.rigPool.acquire(c.pedIndex);
if (a) {
a.setPhase(c.phase);
a.setMoving(c.loiter <= 0, 0); // instant, no fade, so the first frame is posed
a.mixer.update(0); // evaluate NOW — a fresh clone must never show bind-pose (T-pose)
a.fig.scale.setScalar(c.height / (a.nominalHeight || 1.75));
this.group.add(a.fig);
c.actor = a; c.actorKind = 'rig'; return true;
}
return false; // pool exhausted this frame → stay an impostor
}
// placeholder mode (asset-free) — build a unique-coloured figure
const ph = makePlaceholder(rng(this.citySeed, 'body', c.id), { height: c.height });
this.group.add(ph.fig);
c.actor = ph; c.actorKind = 'placeholder'; return true;
}
_releaseActor(c) {
if (!c.actor) return;
this.group.remove(c.actor.fig);
if (c.actorKind === 'rig' && this.rigPool) this.rigPool.release(c.actor);
else c.actor.dispose?.();
c.actor = null; c.actorKind = null; c._acc = 0;
}
setPaused(p) { this.paused = !!p; }
// ---- the frame ----
update(dt) {
if (this.paused) return this.stats; // tab hidden → mixers frozen (app drives this via events)
dt = Math.min(dt, 0.1); // clamp long frames (tab refocus) so nobody teleports
// active population from slider × time-of-day
const active = Math.min(this.roster.length, Math.round(this.target * densityAt(this.timeOfDay)));
this._ensureRoster(active);
// deactivate anyone past the active prefix
for (let i = active; i < this.roster.length; i++) {
const c = this.roster[i];
if (c.tier !== 'far') { this._releaseActor(c); c.tier = 'far'; }
}
// advance + measure distances for the active set
const cam = this.camera;
const camX = cam.position.x, camZ = cam.position.z;
const near = this._nearList; near.length = 0;
const cand = [];
for (let i = 0; i < active; i++) {
const c = this.roster[i];
this._advance(c, dt);
const ddx = c.x - camX, ddz = c.z - camZ;
c._d = Math.hypot(ddx, ddz);
cand.push(c);
}
// choose the near set: hysteresis eligibility, then nearest-first up to the cap
const eligible = [];
for (const c of cand) {
const wasNear = c.tier === 'near';
if (c._d < (wasNear ? NEAR_EXIT : NEAR_ENTER)) eligible.push(c);
}
eligible.sort((a, b) => (a._d - (a.tier === 'near' ? NEAR_BIAS : 0)) - (b._d - (b.tier === 'near' ? NEAR_BIAS : 0)));
const nearSet = new Set(eligible.slice(0, NEAR_MAX));
// assign tiers + representations
const mid = this._midList; mid.length = 0;
let newRigs = 0;
for (const c of cand) {
let want;
if (nearSet.has(c)) want = 'near';
else if (c._d < (c.tier === 'far' ? MID_ENTER : MID_EXIT)) want = 'mid';
else want = 'far';
if (want === 'near') {
// acquire an actor (budgeted); if it fails this frame, ride as an impostor instead
if (!c.actor) {
if (newRigs < NEW_RIG_PER_FRAME && this._acquireActor(c)) newRigs++;
else want = 'mid';
} else if (c.actorKind === 'placeholder' && this.mode === 'rig') {
this._releaseActor(c); // upgrade path: swap placeholder → rig
if (newRigs < NEW_RIG_PER_FRAME && this._acquireActor(c)) newRigs++; else want = 'mid';
}
}
if (want !== 'near' && c.actor) this._releaseActor(c);
if (want === 'near' && c.actor) {
near.push(c);
const a = c.actor;
a.fig.position.set(c.x, 0, c.z);
a.fig.rotation.y = c.facing;
a.setMoving?.(c.loiter <= 0);
} else if (want === 'mid') {
mid.push(c);
}
c.tier = want;
}
// near animation: rigs on a staggered mixer budget; placeholders tick cheaply every frame
near.sort((a, b) => a._d - b._d);
const t0 = (typeof performance !== 'undefined' ? performance.now() : 0);
const extra = [];
for (let i = 0; i < near.length; i++) {
const c = near[i], a = c.actor;
c._acc += dt;
if (a.mixer) {
if (i < MIXER_ALWAYS) { a.mixer.update(c._acc); c._acc = 0; } else extra.push(c);
} else {
a.tick?.(c._acc, c.loiter <= 0); c._acc = 0; // placeholder
}
}
// round-robin the mixers beyond the nearest 8, ≤MIXER_EXTRA per frame
if (extra.length) {
if (this._mixerCursor >= extra.length) this._mixerCursor = 0;
for (let k = 0; k < MIXER_EXTRA && k < extra.length; k++) {
const c = extra[(this._mixerCursor + k) % extra.length];
c.actor.mixer.update(c._acc); c._acc = 0;
}
this._mixerCursor = (this._mixerCursor + MIXER_EXTRA) % extra.length;
}
const mixerMs = (typeof performance !== 'undefined' ? performance.now() : 0) - t0;
// mid tier: one instanced draw call
if (this.impostor) {
const list = mid.length > IMPOSTOR_MAX ? mid.slice(0, IMPOSTOR_MAX) : mid;
this.impostor.update(list, cam);
}
// debug tier tint on near actors (rig materials are shared → tint the whole group cheaply instead)
this.stats = {
active, rigged: near.length, mid: mid.length, far: active - near.length - mid.length,
mixerMs, poolTotal: this.rigPool ? this.rigPool.total : 0, mode: this.mode,
};
return this.stats;
}
dispose() {
this.roster.forEach(c => this._releaseActor(c));
if (this.rigPool) this.rigPool.dispose();
if (this.impostor) { this.group.remove(this.impostor.mesh); this.impostor.dispose(); }
this.scene.remove(this.group);
}
}

Binary file not shown.

Binary file not shown.

BIN
web/models/peds/idle.glb Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
web/models/peds/walk.glb Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.