PROCITY/docs/LANES/LANE_B_NOTES.md
m3ultra 46127c7e95 Lane B (Streetscape) round 13: v3.0-beta the district — N-venue frontage + night pass + posters + queue zone (?gigs=1)
Generalises the R12 one-pub slice to all 2-4 venues and lands the night-readability polish. All
gig-gated (flags-off byte-identical, verified in a fresh browser context). Files: venue.js, audio.js, dbg.js.

- venue.js -> N venues: per-venue lit marquee/frontage on B's +Z facade side + window-light spill;
  streetlamp pools on venue blocks (one instanced warm-pool mesh, night-driven -> the Friday-night
  walk reads); town-wide posters instanced per gig (14 posters = 2 draws / 2 textures, band-name
  overprint, flat printed-poster fallback). Town-wide pools deferred (would need always-on
  furniture.js -> breaks flags-off parity).
- Queue-zone marker per venue: venue.queueZone = {x,z,ry,len} for Lane D (head at the door, extends
  along the frontage; queuer faces -ry). queueZones{} + queueZoneFor(id).
- audio.js muffled spill per-genre (debt #1): import gigKeyFor; the nearest venue at doors/on within
  26 m spills its gig-<genreKey> through the 470 Hz lowpass, so pub/band_room/RSL crossfade genre.
  F: delete the GIG_BED bridge.
- update(gigStates) polymorphic: per-venue map {[venueShopId]:state} (F wires PROCITY.gigs.byVenue)
  or alpha-compat bare string (primary pub). Contract documented for F in LANE_B_NOTES.
- ?noassets fetch fix (latent R12 bug): gate the poster JPEG fetch behind !noassets -> 0 gig-layer
  network over the 22-JPEG facade/ground baseline.
- dbg.js: venue_night + district_posters bookmarks (seg 5) so tools/qa/gig_shot.py scripts the
  street money shot; poseForVenue resolves from the live plan.

Budget (fresh Chromium, gigs-on NIGHT, busiest pub block, all frontages lit): worst 154 scene /
~167 total draws, 64k tris -- <=300 / <=200k. 0 console errors (gigs / gigs+noassets / flags-off).
Verified 3 venues seed 20261990: pub->gig-pubrock, RSL->gig-covers, band_room->gig-grunge (all in
E's manifest).

Finding for Lane A: buildPosters item 1 seats each venue-frontage poster on the lot -Z (back wall,
measured 15.9 m from the +Z street facade) -- flip to +sin,+cos. Spine/pole posters render correctly
and face the footpath ("SCREAMING UTES" readable). Non-blocking (marquee + pools carry the shot).

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

487 lines
36 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 13 (Fable's ROUND13 → Lane B) — the district reads by night (`?gigs=1`, v3.0-beta)
Four B pieces, all gig-gated (flags-off byte-identical, verified). Files: `venue.js` (rewrite),
`audio.js` (spill), `dbg.js` (bookmarks). Verified in a **fresh browser context** (the stale-module
trap bit me once here — the pre-warmed tab served R12 `venue.js`; a `cache:'reload'` + hard nav fixed
it, proof = the returned object's key set flipped from R12 `{frontage,venueShopId}` to R13
`{venues,venueShopIds,queueZones,…}`). Seed 20261990 = **Boolarra Heads, 3 venues**: The Exchange
Hotel (pub/pubrock, id116), Wangaratta RSL (rsl/covers, id404), The Vinyl Shed (band_room/grunge,
id490); 14 gigs/week, tonight **Screaming Utes @ pub $10** + **The Dead Ringers @ RSL free**.
### 1. `venue.js` generalised to N venues — `createVenuePresentation(plan, skins, scene)`
Now renders **every** `plan.shops` with `s.venue`, not just the pub. Returns
`{ group, venues[], update(gigStates), dispose, venueShopId (primary/alpha compat), venueShopIds[],
queueZones{}, queueZoneFor(id), frontage (primary, compat), posterCount, posterDraws }`.
- **Frontage per venue** — warm marquee slab + emissive bulb row over the door, on B's **+Z facade
side** (`sin ry, cos ry`; buildings.js canon), plus a **window-light spill** wash (additive, faint
night baseline + gig glow). Ramped per-venue by `update()`.
- **Streetlamp pools (the night-readability pass)** — one procedural warm radial sprite, **one
InstancedMesh for the whole district** (door pool + 2 footpath pools per venue). Night-driven (on at
NIGHT, off by day — streetlamps don't care about gigs), so venue blocks read on the Friday-night walk.
Scoped to venue blocks; **town-wide deferred on purpose** (would need always-on `furniture.js`
changes → breaks flags-off parity).
- **Posters, town-wide, instanced per gig.** A given gig's poster is identical wherever pasted, so B
groups `plan.posters` by `gigId`, picks ONE seeded skin per gig, and renders each group as a single
`InstancedMesh`**14 posters tonight = 2 draws / 2 canvas textures** (was 1 mesh + 1 canvas each).
Band name overprinted in the skin's `nameZone`; flat printed-poster fallback on 404 / `?noassets`.
- **`?noassets` fetch fix (latent R12 bug).** R12 `posterTexture` fetched `poster-*.jpg` even under
`?noassets`. R13 gates the fetch behind `!noassets` (read from the URL, like buildings.js `WINMAP`).
Measured `?noassets=1&gigs=1`: **0 poster fetch, 0 audio/manifest fetch** — the gig layer adds no
network over the documented 22-JPEG facade/ground baseline. Venue still builds (flat posters), no crash.
### 2. `audio.js` muffled spill → per-genre, per-venue (debt #1 closed on B's side)
The R12 spill hard-coded `pubrock-live`; E's rename made that a **silent bed** (band mimes). Now B
`import { gigKeyFor } from '../citygen/gigs.js'` (the ONE genre→bed map) and spills each venue's
`gig-<genreKey>`. Walks the venue list, the **nearest venue at doors/on within 26 m wins**, so
pub→band_room→RSL **crossfades the genre** through the same 470 Hz lowpass. Verified beds resolve to
E's manifest: pub→`gig-pubrock`, RSL→`gig-covers`, band_room→`gig-grunge` (all present). Audio engine
loads `live` with the new citygen import; flags-off it loads fine and the spill loop is skipped
(no `s.venue` shops). **F: delete the `GIG_BED` bridge — B no longer needs it.**
### 3. Queue-zone marker (for Lane D) — `venue.queueZone = { x, z, ry, len }`
Each venue publishes a queue zone: a subtle chalk-pale ground strip **beside the door** (past the
marquee edge, out on the footpath), running **along the frontage**. `queueZones` = `{ [venueShopId]:
zone }`; also `queueZoneFor(id)`. **Contract for D:** `(x,z)` = the head (front of queue, at the door
end); the line **extends** in `(sin ry, cos ry)` for `len` metres; a queuer **faces the door** =
`(sin ry, cos ry)`. D places poses at `head + (sin ry, cos ry)·s` for `s ∈ [0,len]`. Verified 3
zones seat correctly (pub head (30.9, 390.0) beside the door). D's fallback ("straight line from the
door") stays valid if the zone is absent.
### 4. `dbg.js` bookmarks — the street money shots (for `tools/qa/gig_shot.py`)
- **`venue_night`** (seg 5): stands 13 m in front of the **pub** frontage — lit marquee + streetlamp
pools + window spill + queue + posters, dark CLOSED neighbours behind. The street money shot.
- **`district_posters`** (seg 5): the spine at night, poster poles + lit frontages receding.
- New `poseForVenue(kind, dist)` resolves from the live plan (falls back to `streetViewPose` with no
gig layer, so flags-off shots don't crash).
### `update(gigStates)` — the shape to agree with F (F drives it every street frame)
`update` is **polymorphic**: pass `{ [venueShopId]: 'quiet'|'doors'|'on'|'done' }` (a plain object or
Map, or `{ byVenue: {…} }`) for **per-venue** frontage lighting; a **bare state string** is alpha
compat and lights **only the primary pub** (`plan.gigs[0].venueShopId`). **F R13 ask:** build
`window.PROCITY.gigs.byVenue = { [venueShopId]: { state, cover, paid, … } }` (keep the alpha single
object as a compat alias) and call `venuePresentation.update(PROCITY.gigs.byVenue)`. **audio.js reads
`PROCITY.gigs.byVenue[id].state` too** (falls back to the alpha alias, then a clock fallback from
`plan.gigs` night 0), so the spill works across the transition. Until F wires the map, only the pub
frontage marquee lights from the alpha string — **pools + baseline window glow (night-driven) light
all venue blocks regardless**, so the district still reads at night.
### Budget (measured, fresh Chromium, gigs-on, NIGHT, all frontages lit, busiest = pub block)
| view | scene draws | tris | gate |
|---|---|---|---|
| `venue_night` (composer total) | **96113** | 1932k | ≤300 / ≤200k ✅ |
| pub block 3/4 street (worst of 4 poses) | **154 scene (~167 total)** | **64k** | ✅ ~130 draw margin |
| `district_posters` (composer total) | 142 | — | ✅ |
Gig-night layer cost is tiny: pools **1 draw**, posters **≤2 draws** (instanced per gig), frontage
~3 draws/venue-in-frame. `?noassets=1&gigs=1` worst 176198 (post passes vary), still ≤300. **0
console errors** in every mode (gigs / gigs+noassets / flags-off).
### → Lane A (a finding for you — cross-lane, please flip one sign)
`buildPosters` **item 1** seats each venue's own frontage poster at `lot + (sin ry, cos ry)·(d/2)`
— the lot's **Z** side. But B's facades/doors are on **+Z** (buildings.js canon since round 1, and
the shipped town the player faces), so the venue-frontage poster lands on the **back wall**: measured
for the pub it's **15.9 m from the street facade, 0.07 m from the back**. The CITY_SPEC "front = Z"
poster convention disagrees with the building convention — a street-side sibling of debt #4's RY
clash. **Fix:** item 1 → `+sin, +cos` (the spine-run posters, which use the pole outward-normal
derivation, render correctly and face the footpath — verified "SCREAMING UTES" readable, not
mirrored). B renders A's `(x,z,ry)` verbatim (mesh oriented `ry+π` to face A's Z normal), so once A
flips, the pub gets its poster with no B change. Not blocking the money shot (marquee + pools carry it).
---
## Round 9 (Fable's ROUND9 → Lane B) — v2 tour bookmarks + the tram (`?tram=1`)
Two deliverables, both v2-law clean (default boot untouched, golden hash frozen, `qa.sh --strict` GREEN):
### 1. v2 tour bookmark poses (`dbg.js`) — F shoots these
Added **8 bookmarks** to `js/world/dbg.js` (16 total now), all measured un-letterboxed at 113188 draws:
`rain_verandah` (the money shot — open VIDEO shop w/ winmap parallax, CLOSED dark neighbours, night rain),
`patronage_door`, `dig_real`, `katoomba_main`, `tram_stop`, `rain_street`, `night_crowd`, `shopfront_detail`.
Best combo to shoot the hero: `?winmap=1&weather=rain&dbg=1``DBG.shot('rain_verandah')` at night seg 5.
`tram_stop` now picks the **nearest-origin** shelter stop (most open framing — the spine-end stop was
boxed in by big buildings); frames the `bus_shelter` cleanly at midday.
### 2. Tram (`?tram=1`) — new file `js/world/tram.js`, NON-BLOCKING for the tag
One small bus/tram, out-and-back loop along the **main-street spine**, door-dwell (3.5 s) at each
`busShelterStops` stop, ping-pong reverse at the ends. **Deterministic** (position is a pure function of
elapsed time — verified byte-identical across fresh instances). **Exactly 2 draws** (textured body +
merged headlight pair — verified via `renderer.info` delta). Composes with weather/night: headlights
`emissiveIntensity` jumps 0.15→1.6 when `lighting.isNight()`. "TOWN LOOP" canvas livery. `ring()` is a
door-bell stub (audio still parked). Flag-off is byte-identical (shell never constructs it).
#### → Fable (F, shell wiring — I don't touch `index.html`)
```js
import { createTram } from './js/world/tram.js';
const tram = params.get('tram') && params.get('tram') !== '0'
? createTram({ scene, plan, camera, lighting })
: null; // default-off; needs lighting.isNight() for night headlights
// in the street branch of the main loop: if (tram) tram.update(dt);
```
Flags-table row: `?tram=1` — owner B — landed, default-off, self-contained module (F wires the 2 lines
above). Smoke: boot `?tram=1`, assert `scene.getObjectByName('tram')` present + tram-group render delta
≤2 draws. No collision in v0 (player walks through — noted for v3). `lighting.isNight()` is assumed on
the lighting object; if F's lighting exposes night differently, pass a predicate — tram degrades to the
day headlight value if `isNight` is absent (guarded).
## Round 8 (Fable's ROUND8 → Lane B) — weather (`?weather=1`), carried from R7
New file `js/world/weather.js` + a one-line `setSky()` on `lighting.js`. Seeded, deterministic, cheap.
- **Weather state** — `weatherFor(citySeed, day=0)``{ state, intensity }`, weights ~57% clear /
24% overcast / 19% rain (AU coastal-town plausible; verified over 400 seeds). `day=0` today (one
day cycle); a future day-counter rolls new weather. **Seed 20261990 (default town) rolls `clear`**,
so `?weather=1` there is v1-identical — use **`?weather=rain`** (or `overcast`) to force a state for
demos/smokes, or a rainy seed (7, 424242).
- **Rain** — ONE draw: a **camera-following `THREE.Points`** layer with a procedural streak sprite and
a fall+wind vertex shader (no CPU per-particle). Plus **wet ground** (darken + drop roughness on the
shared ground materials, restored on dispose) and a **matching rainy sky dome** (`lighting.setSky` →
summer-storm/monsoon/cold-front). Overcast: greyer dome, no particles. Fully procedural — 0 depot/GLB
fetch (the sky swap is a skin JPEG, same class as the town's own).
- **Budget/composition/determinism**: worst view **141 draws (day rain) / 126 (night)** — ≤300.
Composes with `?winmap` + night — **rain outside a lit interior-mapped window is the money shot**
(`docs/shots/laneB/weather_rain_night_winmap.png`). Deterministic per seed. `?noassets` compatible.
**0 console errors** in all states. **Flag-off is byte-identical** (the shell simply never constructs
weather). `qa.sh --strict` GREEN. *(Deferred, "if cheap": vertex-shader wind sway on trees/awnings —
needs touching furniture's tree material + skins' awning material; noted, not done this round.)*
### `window.PROCITY.weather` contract (Lane D consumes this — decision #1)
Shape: **`{ state: 'clear' | 'overcast' | 'rain', intensity: 0..1 }`**, stable for the session.
`intensity` is 0 for clear, ~0.30.6 overcast, ~0.451.0 rain. Set by `createWeather` when the flag is
on; **F sets `{ state:'clear', intensity:0 }` when off** so D always reads a valid value (below). D:
read it, don't import weather.js.
### → Fable (F, shell wiring — I don't touch `index.html`)
```js
import { createWeather } from './js/world/weather.js';
const wp = params.get('weather');
const weather = (wp && wp !== '0')
? createWeather({ scene, plan, skins, lighting, camera, force: /^(clear|overcast|rain)$/.test(wp) ? wp : null })
: null; // seeded when ?weather=1; forced when ?weather=rain|overcast|clear
window.PROCITY.weather = weather ? weather.state : { state: 'clear', intensity: 0 };
// in the street branch of the main loop: if (weather) weather.update(dt);
```
Flags-table row: `?weather=1` — owner B — landed, default-off, self-contained module (F wires the 3
lines above). Smoke: boot `?weather=rain`, assert rain Points present + `PROCITY.weather.state==='rain'`,
0 errors.
---
## 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).
## WebAudio engine — `audio.js` (round 11, the audio round)
`web/js/world/audio.js` — one `AudioContext`, unlocked on the first user gesture. Self-contained: it
reads live state off `window.PROCITY` and self-ticks (its own rAF), so the shell only calls
`createAudioEngine(window.PROCITY, { noassets })` once (after `window.PROCITY` is assigned) and stores
it at `window.PROCITY.audio`. Consumes Lane E's `manifest.audio` (self-fetched, **3× retry** so a
dropped boot-burst fetch doesn't mute the session).
Layers, all crossfaded off systems that already exist (gains ramp; the update path allocates nothing):
- **Ambience** — `street-day` / `street-night` beds crossfade on `lighting.getClock().night`; **rain**
bed gain follows `PROCITY.weather.intensity` (silent unless it's actually raining, i.e. `?weather`).
- **Footsteps** — distance-accumulated from `player.position` while pointer-locked (a step per stride).
- **Shop-door spill** — nearest *open* music shop (record/milkbar/video/arcade/dept) within ~9 m leaks
its interior music bed at low distance-gain (scan throttled to 5 Hz over live door meshes; honours
`PROCITY.isOpen`). Verified: 6 m from "Video Barn" → `video-synth` at gain ~0.08.
- **Tram** — rumble gain by camera↔tram distance (`scene.getObjectByName('tram')`); bell rings once as
it settles at a nearby stop. Conditional on `?tram`.
- **SFX** — `procity:enterShop` → doorbell + door-open; `playSfx(key)` for the rest (till/riffle/toast).
**House audio law — all verified in-browser:** silent-and-happy (missing/failed audio → silence, no
errors); **nothing plays or fetches before the first gesture**; `?mute=1` → a silent surface (exists
for smokes, never unlocks, 0 fetches); `?noassets=1` → live but **0 audio/manifest fetches**; beds
lazy-load (≤25 MB pack, 8.7 MB shipped); spill/music selection is a pure function of shop type.
### Surface (`window.PROCITY.audio`, for Lane F's smokes + interior wiring)
- `setMasterGain(0..1)`, `mute()`, `unmute()`, `get ready`, `get muted`.
- `playSfx(key, {gain})`, `footstep(surface)`.
- **`playInterior({ musicKey, toneKey })` / `stopInterior()`** — Lane F calls these from
`interior_mode` on enter/exit, passing **Lane C's `room.audio` contract** (a `musicKey` into
`manifest.music`, a `toneKey` into `manifest.ambience`). The street beds auto-duck to 0 while
`mode !== 'street'`. Confirmed working: `playInterior` fades the room's music+tone in, `stopInterior`
fades them out. **→ Lane C: `room.audio = { musicKey, toneKey }` on `buildInterior` is the right shape.**
- `state``{ ready, muted, mode, manifest, loaded, layers, nearestSpill }` diagnostics for smokes.
## Venue / gigs — the pub from the street (round 12, v3.0-alpha, `?gigs=1`)
Two B-owned pieces for the one-pub vertical slice. **Prime flag law:** both are inert/absent without
the gig layer, so flags-off boot is byte-identical (goldens untouched — no citygen edits).
- **`web/js/world/venue.js`** (new, like weather.js/tram.js — the **shell/Lane F constructs it under
`?gigs`**). `createVenuePresentation(plan, skins, scene) → { group, frontage, update(gigState),
dispose, venueShopId, posterCount }`.
- **Posters** — renders E's `skins.poster.{grunge,retro,screenprint,xerox}` at every `plan.posters`
position `(x,z,ry)` with the gig's `bandName` overprinted in the skin's `nameZone` (canvas
composite, like a shop sign). Seeded template per poster. Flat printed-poster fallback if the JPEG
404s (house law). Verified: "THE WOMBATTS" over the band-photo art. *(Note to A: main-street poster
positions land mid-road on intersection nodes — a verge/pole offset would seat them better.)*
- **Lit frontage** — a warm marquee slab + emissive bulb row over the venue door; `update(gigState)`
ramps the glow (`'on'`→full, `'doors'`→warm-up, else dark). F drives it from the gig state machine.
- **Muffled-gig spill** (in `audio.js`, already shell-wired) — extends the R11 door-spill: near the
venue (≤26 m) while a gig is at `doors`/`on`, the `pubrock-live` bed plays through a **470 Hz
lowpass** (thump through the bricks) at low distance-gain. Gig state = `window.PROCITY.gigs` (Lane F)
with a clock+`plan.gigs` fallback so it works before F lands. Inert without the gig layer.
### For Lane F (wiring this into the shell)
```js
// under ?gigs (plan already has gigs/posters/venue via generatePlanFor(seed, src, {gigs:true, customBands})):
const venue = createVenuePresentation(plan, skins, scene); // posters + frontage
// in the street loop, drive the frontage from your state machine:
venue.update(PROCITY.gigs.state); // 'quiet'|'doors'|'on'|'done'
```
The muffled spill needs **no wiring**`audio.js` reads `plan.gigs` + `window.PROCITY.gigs` itself.
Set `window.PROCITY.gigs = { state }` (or `{ on: bool }`) and B's spill + fallback both follow it.
**Stretch deferred:** streetlamp-pool night-readability on the venue block (touches shared furniture
rendering + wants a budget-safe scoped decal) — flagged as the next polish, not landed this round.
## Controls
WASD move · shift run · mouse look · click a door · `[` `]` step time-of-day · `T` pause clock ·
`M` map · `P` screenshot · Esc release pointer.