# 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 **1–3 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 **122–168 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 `🔒 — 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 | ~1–8 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 wall−radius (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 (~1–2 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) | **~130–210 total** | ≤ 300 | ✅ | | triangles | 12k–25k | ≤ 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 ~5–8 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 ~8–22 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 0–5)` — 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.