PROCITY/docs/LANES/LANE_D_NOTES.md
m3ultra 9eb1acc61b 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>
2026-07-14 13:43:05 +10:00

125 lines
8.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# LANE D — NOTES (measured budgets + clip wishlist)
*Written by PROCITY-D, 2026-07-14. Standalone verification via `web/citizens_test.html`.
Reference stack ported from `90sDJsim/web/world/index.html` ~405520 (loadRig/spawnRig/_canon/
_rotOnly/head-bone normalize/upgradeStreetPeople). Measurements on the M3 Ultra dev box.*
## What shipped
| file | role |
|---|---|
| `web/js/citizens/rigs.js` | ported rig stack: fleet loader, `_canon`/`canonRig`/`_rotOnly`, `buildFigure` (head-bone height-normalise + feet plant), `spawnRig` (single clip), `makeActor` (walk↔idle crossfade), `pickRig` |
| `web/js/citizens/placeholder.js` | seeded low-poly box humanoid (POLY lock) — walks/idles, planted at y=0, hot-swaps to a rig |
| `web/js/citizens/impostor.js` | 4-yaw sprite-atlas baker + instanced billboard layer (mid tier, 1 draw call) |
| `web/js/citizens/sim.js` | deterministic roster, footpath lanes, near/mid/far LOD, rig pool, staggered mixer budget, time-of-day density |
| `web/js/citizens/keepers.js` | one keeper per shop at the counter slot, idle + greet head/body-turn |
| `web/citizens_test.html` | standalone harness: fixture 3×3 street graph, sliders, tier debug, determinism check |
| `web/models/peds/*` | 19 rigged GLBs + `walk.glb` + `idle.glb`, **byte-identical** copies from 90sDJsim (checksummed) |
## The fleet (copied, never edited)
19 rigged peds (17 normal + 2 comical) + 2 clip-only GLBs, ~37 MB total, copied from
`johnking@100.91.239.7:~/Documents/90sDJsim/web/world/models/peds/` (also present locally at
`~/Documents/90sDJsim/…`). SHA-verified byte-identical (house law: canonical source stays upstream).
- Skeletons canonicalise cleanly: `mixamorig1…`→`mixamorig…`, so **one shared walk clip drives all 19**
and one shared idle clip too. Verified in-scene — a single `walk.glb` animates every ped.
- `walk.glb`/`idle.glb` position tracks + `Hips.quaternion` are stripped (`_rotOnly`) — without this
the different-scale root track inflates peds to giants. Confirmed: no giants/ants across all 19.
## Measured budgets (M3 Ultra, `citizens_test.html`)
Preview note: the in-app browser reports `visibilityState:'hidden'`, which throttles `requestAnimationFrame`,
so the on-screen fps counter reads low (4784). True cost was measured by timing 140 manual
`update()+render()` frames.
**200 citizens, midday (the acceptance fixture):**
| metric | measured | CITY_SPEC / Lane-D budget | verdict |
|---|---|---|---|
| near (rigged) actives | 24 (hard cap) | ≤ 24 | ✅ |
| mid (impostor) | ~122 | — | — |
| far (culled) | ~54 | — | — |
| **mixer update / frame** | **0.4 ms max, ~0.2 ms avg** | < 2 ms | comfortably |
| sim logic / frame | 0.24 ms avg | 4 ms build budget | |
| full frame CPU (update+render) | ~2.6 ms ~380 fps headroom | 60 fps (16.6 ms) | huge margin |
| mid tier draw calls | **1** (69 instances, 138 tris) | 1 per atlas | |
| impostor atlas texture | 2048×640 (16×5 cells, 4 yaws × 19 peds) | 2048, < 512 MB total | (~5 MB) |
| determinism | live roster seed recompute, stable while walking | same seed same crowd | |
**Time-of-day density** (200 base): 00:00 12 active, 06:00 70, 12:00 200, 15:00 150,
21:00 36. Curve in `sim.js DAY_CURVE`.
## ⚠️ Findings for Lane E / Lane F (need a decision)
1. **The inherited peds are ~49.7k tris each — too heavy for the CITY_SPEC 200k-tri "typical view".**
24 near rigs **1.2M tris**; a realistic 16-rig street view already measured **1.02M tris**.
Framerate holds on M-series (GPU eats it), but this leaves *nothing* for Lane B buildings.
**Recommendation:** decimate the ped fleet to ~58k tris in the asset pipeline (Lane E) at 6k/ped
the near cap costs ~144k tris, back within budget. The rig stack is indifferent to poly count;
this is purely an asset job. Until then, `NEAR_MAX` (sim.js) is the throttle.
2. **Draw calls scale with near rigs (~6 calls/ped — each GLB has several sub-meshes/materials).**
16 rigs 144 calls; 24 rigs ~190. Under the 300-call street budget *today*, but tight once
Lane B adds shells. **Recommendation:** merge each ped's sub-meshes by material on load
(`BufferGeometryUtils.mergeGeometries`) ~12 calls/ped. Cheap win, deferred (not blocking).
3. **Peds are metallic-PBR (`metalness≈0.5`) and render black without a `scene.environment`.**
The test scene builds a neutral PMREM; the impostor bake is fed the same env. **Lane B's shell must
set `scene.environment`** (a sky PMREM) or every citizen near and mid goes dark. This is wired:
`CitizenSim` reads `scene.environment` and passes it to the impostor baker.
4. **Impostor material is `toneMapped:false` and does ACES + sRGB itself** (matching
`ACESFilmicToneMapping`, exposure `/0.6`). If Lane B changes the tone-mapping curve or animates
`renderer.toneMappingExposure`, call `sim.impostor.setExposure(e)` (and, for a non-ACES curve,
the in-shader ACES in `impostor.js IMP_FRAG` would need to match). Mid billboards otherwise drift
from the near rigs at the LOD seam.
*Hardened by a 4-dimension adversarial code review (see D-progress.md): 6 confirmed defects fixed
non-deterministic fleet order, shared-geometry disposal, impostor under-exposure, unrestored
viewport, missing modelMatrix, frozen exposure.*
## Integration hooks for Lane F
- `new CitizenSim({ renderer, scene, camera, citySeed, graph, fleet })` `graph = { nodes:[{id,x,z}],
edges:[{id,a,b,width,kind}] }`. Swap the fixture graph for Lane A's `CityPlan.streets`. Footpath
lanes are derived from edge `width` + a 0.9 m margin; pedestrians keep to the right of travel.
- `sim.setPopulation(n)` (slider / density) · `sim.setTimeOfDay(t01)` (drive from the shell's day
segment) · `sim.setPaused(bool)` (wire to `visibilitychange`) · `sim.update(dt)` each frame.
- **Chunk streaming (TODO for F):** roster is currently whole-graph. The identity fn `identityOf(citySeed,
edgeCount, id)` is chunk-ready key it by `chunkKey+i` per CITY_SPEC and spawn/despawn rosters with
Lane B's chunk build/dispose. The LOD + pool machinery is already per-frame and chunk-agnostic.
- **Keepers:** `keepers.spawn(roomGroup, { x, z, ry, shopId, type })` feed `x,z,ry` from Lane C's
interior counter `places`; call on interior build, `keepers.remove(handle)` on dispose.
`keepers.update(dt, playerPos)` each interior frame.
- `?noassets=1` verified: full placeholder town, mixers=0, zero crashes.
## Clip wishlist (for the mixamo-fetch run — CHECK THE 34 EXISTING CLIPS FIRST)
Only `walk` + `idle` are wired today (they ship with the ped fleet). CITY_SPEC says
`~/Documents/mixamo-fetch/out/` already holds **34 clips (sit, lean, look-around, phone, …)** these
bind directly through the canonical `mixamorig` skeleton exactly like walk/idle, so wiring them is a
data task, not new rig work. **Verify each exists before requesting; never re-download.**
Wanted for richer loiter/keeper behaviour, in priority order:
1. `sit` bench-sit at benches/verandah steps (sim loiter already has the hook; needs the clip).
2. `lean` loiter against a shopfront (window-shopping stops).
3. `look-around` / `idle-look` window-shopping variety + a better keeper greet than the body-turn.
4. `talk` / `gesture` pairs chatting on the footpath (spawn in seeded 2-groups).
5. `carry` / laden walk shoppers with bags after a purchase (content-phase tie-in).
6. `browse` / `reach` keeper stocking shelves; customer reaching a shelf inside interiors.
Bespoke (won't be on Mixamo, hand-author later): crate-riffle at market stalls, till-operation.
## Known limitations (honest)
- Nearmid 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.