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:
parent
42350607a0
commit
9eb1acc61b
138
D-progress.md
Normal file
138
D-progress.md
Normal 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 47–84 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.55–1.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 25–70m 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 0–200, 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
124
docs/LANES/LANE_D_NOTES.md
Normal 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` ~405–520 (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 (47–84). True cost was measured by timing 140 manual
|
||||
`update()+render()` frames.
|
||||
|
||||
**200 citizens, midday (the acceptance fixture):**
|
||||
|
||||
| metric | measured | CITY_SPEC / Lane-D budget | verdict |
|
||||
|---|---|---|---|
|
||||
| near (rigged) actives | 24 (hard cap) | ≤ 24 | ✅ |
|
||||
| mid (impostor) | ~122 | — | — |
|
||||
| far (culled) | ~54 | — | — |
|
||||
| **mixer update / frame** | **0.4 ms max, ~0.2 ms avg** | < 2 ms | ✅ comfortably |
|
||||
| sim logic / frame | 0.24 ms avg | 4 ms build budget | ✅ |
|
||||
| full frame CPU (update+render) | ~2.6 ms → ~380 fps headroom | 60 fps (16.6 ms) | ✅ huge margin |
|
||||
| mid tier draw calls | **1** (69 instances, 138 tris) | 1 per atlas | ✅ |
|
||||
| impostor atlas texture | 2048×640 (16×5 cells, 4 yaws × 19 peds) | ≤ 2048, < 512 MB total | ✅ (~5 MB) |
|
||||
| determinism | live roster ≡ seed recompute, stable while walking | same seed → same crowd | ✅ |
|
||||
|
||||
**Time-of-day density** (200 base): 00:00 → 12 active, 06:00 → 70, 12:00 → 200, 15:00 → 150,
|
||||
21:00 → 36. Curve in `sim.js DAY_CURVE`.
|
||||
|
||||
## ⚠️ Findings for Lane E / Lane F (need a decision)
|
||||
|
||||
1. **The inherited peds are ~49.7k tris each — too heavy for the CITY_SPEC 200k-tri "typical view".**
|
||||
24 near rigs ≈ **1.2M tris**; a realistic 16-rig street view already measured **1.02M tris**.
|
||||
Framerate holds on M-series (GPU eats it), but this leaves *nothing* for Lane B buildings.
|
||||
**Recommendation:** decimate the ped fleet to ~5–8k tris in the asset pipeline (Lane E) — at 6k/ped
|
||||
the near cap costs ~144k tris, back within budget. The rig stack is indifferent to poly count;
|
||||
this is purely an asset job. Until then, `NEAR_MAX` (sim.js) is the throttle.
|
||||
|
||||
2. **Draw calls scale with near rigs (~6 calls/ped — each GLB has several sub-meshes/materials).**
|
||||
16 rigs ≈ 144 calls; 24 rigs ≈ ~190. Under the 300-call street budget *today*, but tight once
|
||||
Lane B adds shells. **Recommendation:** merge each ped's sub-meshes by material on load
|
||||
(`BufferGeometryUtils.mergeGeometries`) → ~1–2 calls/ped. Cheap win, deferred (not blocking).
|
||||
|
||||
3. **Peds are metallic-PBR (`metalness≈0.5`) and render black without a `scene.environment`.**
|
||||
The test scene builds a neutral PMREM; the impostor bake is fed the same env. **Lane B's shell must
|
||||
set `scene.environment`** (a sky PMREM) or every citizen — near and mid — goes dark. This is wired:
|
||||
`CitizenSim` reads `scene.environment` and passes it to the impostor baker.
|
||||
|
||||
4. **Impostor material is `toneMapped:false` and does ACES + sRGB itself** (matching
|
||||
`ACESFilmicToneMapping`, exposure `/0.6`). 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.
|
||||
0
docs/shots/laneD/.gitkeep
Normal file
0
docs/shots/laneD/.gitkeep
Normal file
BIN
docs/shots/laneD/01_near_tier.jpg
Normal file
BIN
docs/shots/laneD/01_near_tier.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
BIN
docs/shots/laneD/02_mid_tier.jpg
Normal file
BIN
docs/shots/laneD/02_mid_tier.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 143 KiB |
BIN
docs/shots/laneD/03_main_street_noon.jpg
Normal file
BIN
docs/shots/laneD/03_main_street_noon.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
BIN
docs/shots/laneD/04_tiers_debug.jpg
Normal file
BIN
docs/shots/laneD/04_tiers_debug.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 173 KiB |
BIN
docs/shots/laneD/05_noassets_placeholders.jpg
Normal file
BIN
docs/shots/laneD/05_noassets_placeholders.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
304
web/citizens_test.html
Normal file
304
web/citizens_test.html
Normal 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
250
web/js/citizens/impostor.js
Normal 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();
|
||||
}
|
||||
}
|
||||
86
web/js/citizens/keepers.js
Normal file
86
web/js/citizens/keepers.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
127
web/js/citizens/placeholder.js
Normal file
127
web/js/citizens/placeholder.js
Normal 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
202
web/js/citizens/rigs.js
Normal file
@ -0,0 +1,202 @@
|
||||
// PROCITY Lane D — the rig stack. Ported faithfully from 90sDJsim web/world/index.html ~405–500
|
||||
// (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
407
web/js/citizens/sim.js
Normal 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 (25–70m): 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.55–1.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);
|
||||
}
|
||||
}
|
||||
BIN
web/models/peds/comical_boy_01.glb
Normal file
BIN
web/models/peds/comical_boy_01.glb
Normal file
Binary file not shown.
BIN
web/models/peds/comical_luchador_01.glb
Normal file
BIN
web/models/peds/comical_luchador_01.glb
Normal file
Binary file not shown.
BIN
web/models/peds/idle.glb
Normal file
BIN
web/models/peds/idle.glb
Normal file
Binary file not shown.
BIN
web/models/peds/man_athletic_01.glb
Normal file
BIN
web/models/peds/man_athletic_01.glb
Normal file
Binary file not shown.
BIN
web/models/peds/man_bouncer_01.glb
Normal file
BIN
web/models/peds/man_bouncer_01.glb
Normal file
Binary file not shown.
BIN
web/models/peds/man_casual_01.glb
Normal file
BIN
web/models/peds/man_casual_01.glb
Normal file
Binary file not shown.
BIN
web/models/peds/man_casual_02.glb
Normal file
BIN
web/models/peds/man_casual_02.glb
Normal file
Binary file not shown.
BIN
web/models/peds/man_casual_03.glb
Normal file
BIN
web/models/peds/man_casual_03.glb
Normal file
Binary file not shown.
BIN
web/models/peds/man_dj_streetwear_01.glb
Normal file
BIN
web/models/peds/man_dj_streetwear_01.glb
Normal file
Binary file not shown.
BIN
web/models/peds/man_elder_01.glb
Normal file
BIN
web/models/peds/man_elder_01.glb
Normal file
Binary file not shown.
BIN
web/models/peds/man_grunge_01.glb
Normal file
BIN
web/models/peds/man_grunge_01.glb
Normal file
Binary file not shown.
BIN
web/models/peds/man_soldier_ww2_01.glb
Normal file
BIN
web/models/peds/man_soldier_ww2_01.glb
Normal file
Binary file not shown.
BIN
web/models/peds/man_suit_01.glb
Normal file
BIN
web/models/peds/man_suit_01.glb
Normal file
Binary file not shown.
BIN
web/models/peds/man_worker_hivis_01.glb
Normal file
BIN
web/models/peds/man_worker_hivis_01.glb
Normal file
Binary file not shown.
BIN
web/models/peds/walk.glb
Normal file
BIN
web/models/peds/walk.glb
Normal file
Binary file not shown.
BIN
web/models/peds/woman_athleisure_01.glb
Normal file
BIN
web/models/peds/woman_athleisure_01.glb
Normal file
Binary file not shown.
BIN
web/models/peds/woman_business_01.glb
Normal file
BIN
web/models/peds/woman_business_01.glb
Normal file
Binary file not shown.
BIN
web/models/peds/woman_casual_01.glb
Normal file
BIN
web/models/peds/woman_casual_01.glb
Normal file
Binary file not shown.
BIN
web/models/peds/woman_casual_02.glb
Normal file
BIN
web/models/peds/woman_casual_02.glb
Normal file
Binary file not shown.
BIN
web/models/peds/woman_chef_01.glb
Normal file
BIN
web/models/peds/woman_chef_01.glb
Normal file
Binary file not shown.
BIN
web/models/peds/woman_smart_01.glb
Normal file
BIN
web/models/peds/woman_smart_01.glb
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user