The Vuntra window trick — a passing pedestrian sees a plausible room through the shop glass with real parallax, no geometry. One shared ShaderMaterial does single-cube interior mapping on the existing window mesh (back wall + seeded shelves + side walls + floor/ceiling); per-shop variation via aTangent/aTint/aSeed attributes, still 1 window draw/chunk. §3.5 held via the shared uHour/uNight + aHours: night → open room glows warm, closed room dark. v2 prime law: WINMAP is read from the URL in buildings.js (self-contained — no index.html seam; F just needs a flags-table row). Flag-OFF byte-identical to v1 (windowQuad → v1 hoursQuad, v1 windowMaterial). Proven: flag-off windows MeshStandardMaterial w/ no winmap attrs; flag-on interior ShaderMaterial; worst view 122-168 draws (<=300); 100% procedural (0 fetch delta vs ?noassets baseline); 0 console errors; GOLDEN.hash untouched. Screenshots in docs/shots/laneB/winmap_*. qa.sh --strict GREEN (4/4). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
16 KiB
LANE B — NOTES (measured)
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 sharedShaderMaterialon 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.
- 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 (
- §3.5 respected via the same
uHour/uNight+aHoursthe 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 indocs/shots/laneB/winmap_*.png. - v2 prime law held.
WINMAPis read straight from the URL in buildings.js — no shell seam, so F only needs a flags-table row, noindex.htmlwiring. Flag-OFF path is byte-identical to v1:windowQuadcollapses to the v1hoursQuad(no extra attributes) and the finalize uses the v1windowMaterial()— verified: flag-off windows areMeshStandardMaterialwith noaTangent; flag-on are the interiorShaderMaterialwithaTangent. Seeded fromshop.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
?noassetsbaseline (both 22 = the pre-existing facade/ground skin JPEGs).?noassets=1&winmap=1adds no network. 0 console errors in all modes.qa.sh --strictGREEN (4/4);GOLDEN.hashuntouched (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
openLatevideo rental at night) render lit windows; the door tooltip reads🔒 <name> — CLOSED · opens HH:00on aim. Batched — the facade atlas is untouched: per-shop open/closed rides on a per-vertexaHours(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 aprocity:segmentevent (numeric hour matching Lane F'scurrentHour()); buildings.js listens and callssetFacadeHours. 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_fringeposes 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 defensivecomposer.setSizesync inDBG.renderper 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_neonnow 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 ≤5kglb_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:
- 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. - 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 mdoubles as the chunk pop-in mask. - Per-chunk streaming = buildings + furniture only.
ChunkManagerkeeps 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.environmentis set (round 3,lighting.attachRenderer): a neutral PMREM'd sky→ground gradient (no asset). Lane D's rig-fleet peds bake their impostor atlas fromscene.environment(sim.js) and go dark if it's null; set here beforeCitizenSimconstructs. 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_modeonly swapsscene.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, andpushOutRect): a lot atry=0faces+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-alignedry. 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 oftoWorld. (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;
generatePlanuses numeric ids → every edge got the same seed. Coerced withString(id). - Furniture drop:
chunkIndexrasterised 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 CbuildInterior; verified end-to-end:enterShop→ modeinterior,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.