PROCITY/docs/LANES/LANE_B_NOTES.md
m3ultra 24cef5a7bb Lane B round-6: place street_bin + bus_shelter GLBs (on the street graph, collision-verified)
Consumes the two orphaned street GLBs via the use-if-ready furniture path (fail-soft; ?noassets →
primitives, 0 network).

- Bins: genuinely sparse (~1/block, s+=78m, seeded side), decoupled from benches — country town not
  a mall. GLB on its 0.57x0.68 footprint.
- Bus shelters ON THE STREET GRAPH (future tram/bus stops): deterministic rule = midpoint of every
  MAIN edge with hashEdge(id)%3===0, road-side footpath by the kerb, long axis along the edge, open
  front to road -> 1-3/town (2 in seed 20261990). Solid oriented-rect collider (furniture now returns
  colliders; chunks.js merges them into getColliders) -> walk-into-it is blocked. Exported
  busShelterStops(plan) -> [{edgeId,x,z,yaw}] as the stop-list contract for the vehicle loop.
- Perf: worst view WITH rig-fleet citizens 94k tris / 148 draws (<=200k / <=300, big margin);
  deterministic; all-flags-on (winmap+dig+roster) composes clean, 0 errors. street_bin GLB is 3k tris
  -> flagged to Lane E as a decimation candidate (bounded by the sparse cadence for now).

qa.sh --strict GREEN.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:04:14 +10:00

253 lines
18 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 B — NOTES (measured)
## Round 6 (Fable's ROUND6 → Lane B) — place the orphaned street props
Placed the two published-but-unconsumed street GLBs (`procity_street_bin_01`, `procity_street_bus_shelter_01`)
via the existing use-if-ready furniture GLB path (fail-soft to primitives; `?noassets` → primitives,
**0 network** verified). All in `furniture.js` (+ a 3-line collider merge in `chunks.js`).
- **Bins** — genuinely sparse now (one loop, ~1 per block: `s += 78 m`, seeded side per edge), a country
town not a mall. Decoupled from benches. GLB (3.0k tris) on its 0.57×0.68 footprint, primitive fallback.
- **Bus shelters — on the street graph, and this is the future tram/bus-stop contract.** Deterministic
rule: **the midpoint of every MAIN edge whose `hashEdge(id) % 3 === 0`, on the road-side footpath by the
kerb, long axis along the edge, open front to the road** — yields **13 per town** (2 in seed 20261990).
The GLB (2.33×2.4×1.41, 8.0k tris) gets a **solid oriented-rect collider** so the player rounds it
(furniture now returns `colliders`; `chunks.js` merges them into `getColliders`). Collision-verified: a
straight walk into it is blocked.
- **→ whoever builds the V2 tram/bus loop:** call **`busShelterStops(plan)`** (exported from
`furniture.js`) → `[{ edgeId, x, z, yaw }]`. It re-derives the stops from the exact same rule the
shelters are placed by — pure function of the plan, zero side effects. That is the stop list; don't
re-invent it. To move/space stops, change the one rule + `busShelterStops` together.
- **Perf (measured, worst view WITH the rig-fleet citizens, midday):** **94k tris · 148 draws** — both
well under the ≤200k / ≤300 gates (~100k tri and ~150 draw margin). Determinism holds (seed → same
props). All-flags-on (`?winmap=1&dig=1&roster=stream`) composes clean, 0 console errors. Shot:
`docs/shots/laneB/bus_shelter.png`. `qa.sh --strict` GREEN.
- **→ Lane E (decimation candidate, not blocking):** the `street_bin` GLB is **3.0k tris** — heavy for
a prop placed ~15×; I bounded it with the sparse cadence, but a decimate to ~500 tris would give back
tri headroom if the combined-lane budget tightens. (Same class as the deferred `novelty_record`.)
---
## Round 5 (Fable's ROUND5 → Lane B) — v2: the Vuntra window trick, behind `?winmap=1`
- **Parallax interior-mapping glass** (V2_IDEAS "window trick"). Behind **`?winmap=1`, default-off.**
Single-cube interior mapping in one shared `ShaderMaterial` on the existing window geometry: each
glass fragment casts the view ray into a virtual room box and shades the surface it hits (back wall
+ seeded shelf silhouettes with product blocks, side walls, floor/ceiling) — a plausible room with
real parallax, **zero extra geometry, still 1 window draw/chunk**. Per-shop variation rides
attributes (`aTangent` = window right vector, `aTint` = seeded room colour, `aSeed` = shelf
variation), **not per-shop materials**. Verified live: rooms are visible through the glass with
parallax day + night.
- **§3.5 respected** via the same `uHour/uNight` + `aHours` the closed-facade system uses: at night an
**open shop's room glows warm** ("Video Regal" lit), a **closed shop's room is dark** ("Second Time"
dark) — screenshots in `docs/shots/laneB/winmap_*.png`.
- **v2 prime law held.** `WINMAP` is read straight from the URL in **buildings.js** — **no shell seam,
so F only needs a flags-table row, no `index.html` wiring.** Flag-OFF path is byte-identical to v1:
`windowQuad` collapses to the v1 `hoursQuad` (no extra attributes) and the finalize uses the v1
`windowMaterial()` — verified: flag-off windows are `MeshStandardMaterial` with no `aTangent`;
flag-on are the interior `ShaderMaterial` with `aTangent`. Seeded from `shop.seed` (deterministic).
- **Budget + fetches:** flag-on worst continuous-walk view with citizens **122168 draws** (≤300);
the shader is **100% procedural** — measured **0 fetch delta** vs the `?noassets` baseline (both 22
= the pre-existing facade/ground skin JPEGs). `?noassets=1&winmap=1` adds no network. **0 console
errors** in all modes. `qa.sh --strict` GREEN (4/4); `GOLDEN.hash` untouched (no plan change).
**→ Fable (F2 flags table):** `?winmap=1` — owner Lane B — state landed, default-off — needs **no
index.html wiring** (self-read in buildings.js). Smoke: boot `?winmap=1`, walk a block, glass shows
rooms; night open=warm / closed=dark.
---
## Round 4 (Fable's ROUND4 → Lane B) — done
- **B1 — closed-facade visuals (§3.5, the headline).** Closed shops now render **dark windows + a
red CLOSED plate**; open shops (esp. the one `openLate` **video rental** at night) render **lit
windows**; the door tooltip reads `🔒 <name> — CLOSED · opens HH:00` on aim. **Batched — the facade
atlas is untouched:** per-shop open/closed rides on a per-vertex `aHours` (vec2 = the shop's
`[open,close]`) feeding TWO shared shaders (window emissive gated by *open AND night*; plate alpha
gated by *closed*), driven by ONE pair of shared uniforms (`uHour/uNight`) updated **once per
day-segment change** — no per-shop materials, no per-frame poll. lighting.js dispatches a
`procity:segment` event (numeric hour matching Lane F's `currentHour()`); buildings.js listens and
calls `setFacadeHours`. Verified live: **22:00 → only "Video Regal" lit, every other facade CLOSED**;
midday → all open, no plates; tooltip correct. **Worst view with citizens: 200 total draws** (≤300).
- **B2 — shot harness (gate-6).** (a) **Letterbox "fixed":** the bars were **not** a composer bug (I
proved the composer/renderer/camera are correctly 1280×720 in headless at every stage) — they were
`street_noon`/`arcade`/`warehouse_fringe` poses **jamming the camera against a big building wall**.
Reframed those bookmarks to look *down a street* (`streetViewPose`), so all 10 tour shots now fill
the frame. Also added a defensive `composer.setSize` sync in `DBG.render` per F's request. (b) Added
the **3 missing poses** F listed (`crossroads_busy`, `residential_collar`, `warehouse_fringe`) —
resolved from the live plan (busiest node / house-or-backstreets lot / outer-ring street), always a
valid full-frame pose. `night_neon` now frames the open video shop glowing amid its CLOSED
neighbours (the §3.5 money shot). `tools/shots.py` → 10 un-letterboxed shots, **0 console errors**.
- **B3 — street-furniture GLB upgrade + `novelty_record`.** furniture.js now upgrades primitives to
depot GLBs via a **synchronous use-if-ready** check at chunk-build (no async write into a live
chunk; `?noassets` → primitives, **0 network** verified). Placed the **novelty_record** landmark on
the footpath in front of every record store (and a food_cart by market stalls / milk bars).
**⚠ measured GLB tris:** bench **1.9k** ✓, food_cart **5.0k** ✓, streetlight **5.0k** ✓, but
**novelty_record is 26.5k** (5× the ≤5k `glb_law`) — × many instances it spiked the busiest view to
**574k tris**. So novelty_record stays on its **low-poly primitive** (giant vinyl disc, budget-safe)
until Lane E decimates the GLB — **same treatment the ped rigs need (→ Lane E)**. With that, the
bench GLB verified sitting on its exact footprint (0.44×1.21), tris back to ~25k.
`bash tools/qa.sh --strict` **GREEN** (4/4) after these changes. Round-4 files: `dbg.js`, `buildings.js`,
`lighting.js`, `hud.js`, `furniture.js` (+ these docs, + `docs/shots/laneB_r4/`).
---
Streetscape + game shell. Files: `web/index.html`, `web/js/world/*`, `web/js/world/fixture_plan.js`.
All numbers measured in-browser (Chromium, M-series) via `renderer.info`, 1280×720-ish viewport.
`renderer.info.autoReset` is off and reset once/frame, so **draws/tris are the true per-frame
totals across all passes** (scene + bloom + output). "scene draws" below excludes the ~11
post-processing passes; "total" adds them.
## Perf — fixture (`fixture_plan.js`, "Yarraville Junction", 26 shops, radius 3, shadows on)
| metric | measured | gate | status |
|---|---|---|---|
| draw calls | **148 scene (~159 total)** | ≤ 300 | ✅ |
| triangles | **~5.8k** | ≤ 200k | ✅ |
| skin materials (city-wide, shared) | **21** | ≤ 25 | ✅ |
| live chunks (radius 3) | 12 | — | — |
| chunk build | ~18 ms | 4 ms/frame budget | drained 1/frame |
Walked the fixture end-to-end (~310 m of main street + the backstreets): contiguous, no seams,
chunks stream in/out silently (live count 8↔12 as you move; build queue never starved), day/night
cycles through 6 segments, signs legible, door tooltips + `procity:enterShop` fire, collision
stops the player at wallradius (measured 10.62 m against an 11 m frontage, radius 0.38). Runs with
`web/assets/` renamed away — no crashes, flat-colour fallback town, canvas signs still legible.
## Perf — Lane A integration (`generatePlan`, "Boolarra Heads", ~493 shops, 1 km², radius 2 auto, shadows off)
Lane A's `generatePlan` landed mid-build; `index.html` prefers it over the fixture (guarded import),
so this is the shipped default now. It is a full city with content in nearly every chunk — far
denser than the fixture the ≤300 gate was sized against.
Streetscape numbers below are **buildings + ground + furniture only** (what Lane B owns), measured by
a scripted continuous walk (real streaming + disposal, so ≤25 live chunks — no orphan-chunk inflation).
**Round-3 re-measure WITH Lane D citizens (pop 140, the shipped default).** Driving the real loop
(`chunks.update` + `citizens.update`) along the dense corridors via `window.DBG`, up to **125 active
peds** at midday: worst continuous-walk view **263 scene / 275 total** at **MIDDAY** and **275 total
at NIGHT** — **≤300 everywhere**, ~25 draw margin. Peds are a batched impostor layer (~12 draws for
the whole near tier), so they cost far less than their count. Radius-2 auto is already the spawn
default for a big city; no config change was needed. (Fable's independent integrated read is ~191 on a
typical main-street view.)
| metric | measured | gate | status |
|---|---|---|---|
| draw calls (worst gameplay view, continuous walk) | **~261 total (249 scene)** | ≤ 300 | ✅ ~40 draw margin |
| draw calls (typical street view) | **~130210 total** | ≤ 300 | ✅ |
| triangles | 12k25k | ≤ 200k | ✅ (huge headroom) |
| skin materials (shared, city-wide) | **5** | ≤ 25 | ✅ (facade atlas collapsed ~22 facade mats → 1) |
| live chunks (radius 2, walking) | ≤ 25 | — | — |
| chunk build | min ~1 / median ~8 / max ~22 ms | 4 ms/frame | ~1 heavy chunk/frame |
Integration verified: Lane B streams and renders Lane A's 493-shop city correctly — facades, signs,
doors, ground, furniture, day/night all work. Collision holds for the arbitrary lot orientations
`generatePlan` emits (e.g. `ry = 1.32 rad`); see the collision fix below.
### Adaptive settings (index.html)
`BIG_CITY = plan.shops.length > 120`. Big city → **radius 2 + no sun-shadow pass**; fixture → radius
3 + shadows. Override with `?r=N` / `?shadows=0|1`. Dropping the shadow pass ≈ halves draw calls in
dense areas (the shadow map re-renders every caster) for little visual loss in a flat-lit low-poly
town — LANE_B doc explicitly allows "none".
### Draw-budget close-out (this session) — the gate now holds with margin
Fable's first integrated smoke test read **~334 draws at spawn** (that was radius 3, pre-adaptive) and
the deep districts appeared to peak ~445. Two things closed the gap:
1. **Facade texture atlas** (`skins.js` + `buildings.js`). Every facade skin packs into one 2048²
atlas (6×6 slots, each pre-painted with the skin's flat fallback colour, then the JPEG drawn over
it — house law preserved), so a chunk's facades merge into **one** mesh (was ~58 per-skin meshes)
and ~22 facade materials collapse to **1** (this also fixes the "≤25 materials was at the cap /
over for some seeds" concern — now **5** city-wide). UV convention mirrors the sign atlas.
2. **Instanced-mesh merges.** Awnings were one InstancedMesh *per skin* (red/green/blue) per chunk;
now **one per chunk** with per-instance colour (the stripe JPEG lived on the awning's unseen top
face, so flat colour reads the same under the verandah). Shells + parapets + posts merged into
**one** InstancedMesh (per-instance colour) instead of two.
Measured effect (worst dense frustum): **393 → 265 scene draws**; worst *continuous-walk* gameplay
view **~261 total**. Note the earlier "445" figures were partly a **measurement artifact** — teleport-
jumping between distant spots left orphan chunks the real streamer disposes; a proper walk never
exceeds the radius-2 set. Verified day + night, facades still textured, awning colours still varied.
## Architecture decisions worth knowing (for Lane F)
- **Ground is built once, not streamed.** The street graph is a handful of edges; merged flat quads
are a few draws total and can't seam. If Lane A ships a many-hundred-edge network, revisit.
- **Sky/lighting are global**, re-centred on the player each frame (dome radius 650, sun follows).
Linear fog at `(radius+0.4)·64 m` doubles as the chunk pop-in mask.
- **Per-chunk streaming = buildings + furniture only.** `ChunkManager` keeps chunks within Chebyshev
radius R, disposes past R+1, drains a nearest-first build queue under a 4 ms/frame budget. A single
dense chunk can exceed 4 ms (median 8), so it builds ~1/frame → an occasional ~822 ms build frame
entering a dense district (fog-masked). Splitting a chunk build across frames is the next step if
that hitch matters.
- **Night lighting is faked** (Vuntra-style): window + streetlamp **emissive** toggled by a night
flag, plus bloom — no per-streetlight real lights. `ChunkManager.setNight()` re-applies to chunks
streamed in while already night.
- **`scene.environment` is set** (round 3, `lighting.attachRenderer`): a neutral PMREM'd sky→ground
gradient (no asset). Lane D's rig-fleet peds bake their impostor atlas from `scene.environment`
(`sim.js`) and go dark if it's null; set here *before* `CitizenSim` constructs. One env for all
segments (the atlas bakes once; the mostly-non-metal streetscape is barely affected). Verified set
in street/night/interior + after interior-return (`interior_mode` only swaps `scene.background`).
- **Shared materials, city-wide** (`skins.js`). Facades share **one atlas material** (all skins in a
2048² atlas); shells + parapets + posts are **one** InstancedMesh/chunk (per-instance colour); all
awning/canopy skins are **one** InstancedMesh/chunk (per-instance colour). Signs are a per-chunk
canvas atlas sized to the chunk's actual sign count, mipmaps off. Net: ~1 draw per kind per chunk.
- **Orientation law** (obeyed by `buildings.toWorld`, the InstancedMesh shells, and `pushOutRect`):
a lot at `ry=0` faces `+Z`; `local→world = RotY(ry)`. Collider and geometry share this convention.
## Fixes applied after an adversarial multi-agent review (this session)
- **Collision for arbitrary `ry`** (`planutil.pushOutRect`): world→local used the wrong rotation sign;
correct only for axis-aligned `ry`. The fixture is axis-aligned so it passed, but Lane A's angled
lots would let you walk through walls. Fixed to the true inverse of `toWorld`. **(Critical for Lane A.)**
- **House doors** were merged into the interactive door mesh with no `doorRects` → raycasting a house
door mis-resolved to the nearest shop. Now a separate non-picked mesh.
- **Awning skin index** used a signed shift (`seed >> 3`) → negative index → undefined skin for ~⅓ of
shops. Now `>>> 3`.
- **Sign atlas** was a fixed 2048² (~16 MB canvas + ~22 MB GPU each) per shop-chunk regardless of
sign count. Now sized to the actual sign count, mipmaps off — critical at Lane A's density.
- **Furniture edge hashing** assumed string edge ids; `generatePlan` uses numeric ids → every edge
got the same seed. Coerced with `String(id)`.
- **Furniture drop:** `chunkIndex` rasterised only the edge centreline, so furniture at a perpendicular
offset could land in an unbuilt chunk and vanish. Rasterisation now covers the full road+verge band.
- **`takeShots()`** never restored the pre-shot mode (P in map mode dumped you into street).
Reviewers' non-issues (intentional): `getColliders` returns a shared scratch array (per-frame
alloc avoidance); per-frame `{clock,chunks}` HUD object (negligible).
## The `shot()` harness
Press **P** → renders 3 fixed cameras and downloads PNGs. Reference stills captured to
`docs/shots/laneB/` (main-street day, plaza overview, night strip). Note: at Lane A's city the fixed
camera poses are tuned for the fixture; re-aim if used against the generated city.
## `window.DBG` — the QA harness hook (this session, for Lane F's shots.py/soak.py)
Loaded only with **`?dbg=1`** (`js/world/dbg.js`, installed by the shell). Answers LANE_F_NOTES §4.
All poses are derived from the **live plan** so bookmarks work on any seed; the day cycle pauses on
the first scripted segment change for deterministic captures.
- `DBG.ready` — true once the first chunks are built + the queue is drained (textures may still decode).
- `DBG.shot(name)` — snaps to a named bookmark, sets its time-of-day, settles bloom, returns stats.
Bookmarks: `street_noon`, `arcade` (dept anchor), `market_square` (stalls), `milkbar_dusk`, `night_neon`.
- `DBG.teleport(x, z, ry)` / `DBG.setSegment(seg 05)` — drive the soak walk + time of day.
- `DBG.enterShop(shopId)` / `DBG.exitShop()` — scripted interior visits (drives Lane F's bridge →
Lane C `buildInterior`; verified end-to-end: `enterShop` → mode `interior`, `exitShop` → back to street).
- `DBG.info()``{ drawCalls, tris, fps, heapMB, geometries, textures, chunk, mode }` (budget/soak read this).
Every bookmark stays under budget (busiest, the dept anchor, ~269 draws). `fps` reads the HUD's
smoothed value (0 under a throttled/background tab; populates in a foreground Playwright run).
## `ChunkManager` lifecycle hooks (optional, LANE_F_NOTES §8)
`createChunkManager` now fires `ctx.onChunkBuilt(key, {cx, cz, buildings, furniture, data})` and
`ctx.onChunkDisposed(key, {cx, cz})` **if a consumer sets them on `ctx`** — a clean per-chunk seam
for spawning/ambient/LOD. Inert by default (the shell doesn't set them; **Lane D's citizens drive off
`plan.streets` instead of chunk lifecycle**, so this is a spare hook, not a dependency).
## Controls
WASD move · shift run · mouse look · click a door · `[` `]` step time-of-day · `T` pause clock ·
`M` map · `P` screenshot · Esc release pointer.