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>
487 lines
36 KiB
Markdown
487 lines
36 KiB
Markdown
# 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) | **96–113** | 19–32k | ≤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 176–198 (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 113–188 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.3–0.6 overcast, ~0.45–1.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 **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 `🔒 <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 | ~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).
|
||
|
||
## 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.
|