diff --git a/D-progress.md b/D-progress.md new file mode 100644 index 0000000..34dfe91 --- /dev/null +++ b/D-progress.md @@ -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. diff --git a/docs/LANES/LANE_D_NOTES.md b/docs/LANES/LANE_D_NOTES.md new file mode 100644 index 0000000..da7d0c3 --- /dev/null +++ b/docs/LANES/LANE_D_NOTES.md @@ -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. diff --git a/docs/shots/laneD/.gitkeep b/docs/shots/laneD/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/shots/laneD/01_near_tier.jpg b/docs/shots/laneD/01_near_tier.jpg new file mode 100644 index 0000000..f9b892a Binary files /dev/null and b/docs/shots/laneD/01_near_tier.jpg differ diff --git a/docs/shots/laneD/02_mid_tier.jpg b/docs/shots/laneD/02_mid_tier.jpg new file mode 100644 index 0000000..4be1290 Binary files /dev/null and b/docs/shots/laneD/02_mid_tier.jpg differ diff --git a/docs/shots/laneD/03_main_street_noon.jpg b/docs/shots/laneD/03_main_street_noon.jpg new file mode 100644 index 0000000..b52b398 Binary files /dev/null and b/docs/shots/laneD/03_main_street_noon.jpg differ diff --git a/docs/shots/laneD/04_tiers_debug.jpg b/docs/shots/laneD/04_tiers_debug.jpg new file mode 100644 index 0000000..6567932 Binary files /dev/null and b/docs/shots/laneD/04_tiers_debug.jpg differ diff --git a/docs/shots/laneD/05_noassets_placeholders.jpg b/docs/shots/laneD/05_noassets_placeholders.jpg new file mode 100644 index 0000000..e3cdad6 Binary files /dev/null and b/docs/shots/laneD/05_noassets_placeholders.jpg differ diff --git a/web/citizens_test.html b/web/citizens_test.html new file mode 100644 index 0000000..95208d4 --- /dev/null +++ b/web/citizens_test.html @@ -0,0 +1,304 @@ + + + + + +PROCITY · Lane D — Citizens test + + + + +
+
+

Lane D · Citizens

+
fps
+
+
rigged (near)0
+
impostor (mid)0
+
culled (far)0
+
active0
+
+
mixer ms0
+
draw calls0
+
triangles0
+
rig pool0
+
mode
+ +
+ + +
+
+ + +
+
+ + +
+
+
+
+
drag = look · wheel = zoom · WASD = move focus · Q/E = up/down
+ + + + diff --git a/web/js/citizens/impostor.js b/web/js/citizens/impostor.js new file mode 100644 index 0000000..1b83444 --- /dev/null +++ b/web/js/citizens/impostor.js @@ -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(); + } +} diff --git a/web/js/citizens/keepers.js b/web/js/citizens/keepers.js new file mode 100644 index 0000000..0698c12 --- /dev/null +++ b/web/js/citizens/keepers.js @@ -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; + } + } + } +} diff --git a/web/js/citizens/placeholder.js b/web/js/citizens/placeholder.js new file mode 100644 index 0000000..1e8f802 --- /dev/null +++ b/web/js/citizens/placeholder.js @@ -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 }; +} diff --git a/web/js/citizens/rigs.js b/web/js/citizens/rigs.js new file mode 100644 index 0000000..5065518 --- /dev/null +++ b/web/js/citizens/rigs.js @@ -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) }; +} diff --git a/web/js/citizens/sim.js b/web/js/citizens/sim.js new file mode 100644 index 0000000..d9f1fc0 --- /dev/null +++ b/web/js/citizens/sim.js @@ -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); + } +} diff --git a/web/models/peds/comical_boy_01.glb b/web/models/peds/comical_boy_01.glb new file mode 100644 index 0000000..5f99b50 Binary files /dev/null and b/web/models/peds/comical_boy_01.glb differ diff --git a/web/models/peds/comical_luchador_01.glb b/web/models/peds/comical_luchador_01.glb new file mode 100644 index 0000000..5744719 Binary files /dev/null and b/web/models/peds/comical_luchador_01.glb differ diff --git a/web/models/peds/idle.glb b/web/models/peds/idle.glb new file mode 100644 index 0000000..66ae703 Binary files /dev/null and b/web/models/peds/idle.glb differ diff --git a/web/models/peds/man_athletic_01.glb b/web/models/peds/man_athletic_01.glb new file mode 100644 index 0000000..c5efeda Binary files /dev/null and b/web/models/peds/man_athletic_01.glb differ diff --git a/web/models/peds/man_bouncer_01.glb b/web/models/peds/man_bouncer_01.glb new file mode 100644 index 0000000..e993436 Binary files /dev/null and b/web/models/peds/man_bouncer_01.glb differ diff --git a/web/models/peds/man_casual_01.glb b/web/models/peds/man_casual_01.glb new file mode 100644 index 0000000..231c8f0 Binary files /dev/null and b/web/models/peds/man_casual_01.glb differ diff --git a/web/models/peds/man_casual_02.glb b/web/models/peds/man_casual_02.glb new file mode 100644 index 0000000..726abd6 Binary files /dev/null and b/web/models/peds/man_casual_02.glb differ diff --git a/web/models/peds/man_casual_03.glb b/web/models/peds/man_casual_03.glb new file mode 100644 index 0000000..0b9c212 Binary files /dev/null and b/web/models/peds/man_casual_03.glb differ diff --git a/web/models/peds/man_dj_streetwear_01.glb b/web/models/peds/man_dj_streetwear_01.glb new file mode 100644 index 0000000..bacdd16 Binary files /dev/null and b/web/models/peds/man_dj_streetwear_01.glb differ diff --git a/web/models/peds/man_elder_01.glb b/web/models/peds/man_elder_01.glb new file mode 100644 index 0000000..d09bb45 Binary files /dev/null and b/web/models/peds/man_elder_01.glb differ diff --git a/web/models/peds/man_grunge_01.glb b/web/models/peds/man_grunge_01.glb new file mode 100644 index 0000000..48f99ca Binary files /dev/null and b/web/models/peds/man_grunge_01.glb differ diff --git a/web/models/peds/man_soldier_ww2_01.glb b/web/models/peds/man_soldier_ww2_01.glb new file mode 100644 index 0000000..e512ff6 Binary files /dev/null and b/web/models/peds/man_soldier_ww2_01.glb differ diff --git a/web/models/peds/man_suit_01.glb b/web/models/peds/man_suit_01.glb new file mode 100644 index 0000000..060027d Binary files /dev/null and b/web/models/peds/man_suit_01.glb differ diff --git a/web/models/peds/man_worker_hivis_01.glb b/web/models/peds/man_worker_hivis_01.glb new file mode 100644 index 0000000..55c0967 Binary files /dev/null and b/web/models/peds/man_worker_hivis_01.glb differ diff --git a/web/models/peds/walk.glb b/web/models/peds/walk.glb new file mode 100644 index 0000000..f85f347 Binary files /dev/null and b/web/models/peds/walk.glb differ diff --git a/web/models/peds/woman_athleisure_01.glb b/web/models/peds/woman_athleisure_01.glb new file mode 100644 index 0000000..1b052d9 Binary files /dev/null and b/web/models/peds/woman_athleisure_01.glb differ diff --git a/web/models/peds/woman_business_01.glb b/web/models/peds/woman_business_01.glb new file mode 100644 index 0000000..51d61bb Binary files /dev/null and b/web/models/peds/woman_business_01.glb differ diff --git a/web/models/peds/woman_casual_01.glb b/web/models/peds/woman_casual_01.glb new file mode 100644 index 0000000..62acfa8 Binary files /dev/null and b/web/models/peds/woman_casual_01.glb differ diff --git a/web/models/peds/woman_casual_02.glb b/web/models/peds/woman_casual_02.glb new file mode 100644 index 0000000..ae477ae Binary files /dev/null and b/web/models/peds/woman_casual_02.glb differ diff --git a/web/models/peds/woman_chef_01.glb b/web/models/peds/woman_chef_01.glb new file mode 100644 index 0000000..e6105ab Binary files /dev/null and b/web/models/peds/woman_chef_01.glb differ diff --git a/web/models/peds/woman_smart_01.glb b/web/models/peds/woman_smart_01.glb new file mode 100644 index 0000000..e6dede3 Binary files /dev/null and b/web/models/peds/woman_smart_01.glb differ