PROCITY/docs/LANES/LANE_B_NOTES.md
m3ultra 900b274a9a Lane B round-8: weather (?weather=1) — seeded rain/overcast/clear, exposes PROCITY.weather for D
New js/world/weather.js + a one-line lighting.setSky(). Seeded per citySeed (weatherFor):
~57/24/19% clear/overcast/rain over 400 seeds, deterministic. Rain = ONE draw (camera-following
Points + procedural streak/fall/wind shader) + wet ground (roughness/darken on shared ground mats,
restored on dispose) + matching rainy sky dome via lighting.setSky. Fully procedural (0 depot fetch).

- Exposes window.PROCITY.weather = { state:'clear'|'overcast'|'rain', intensity:0..1 } for Lane D's
  weather-reactive crowds (decision #1). Documented the contract + the exact 3-line shell wiring for F
  in LANE_B_NOTES (I don't touch index.html). ?weather=1 seeded (default town rolls clear);
  ?weather=rain|overcast|clear forces a state for demos/smokes.
- Worst view 141 draws day / 126 night (<=300); composes with ?winmap + night (rain outside a lit
  interior-mapped window = the money shot, screenshotted); deterministic; 0 console errors; flag-off
  byte-identical (shell never constructs it). Deferred cut line: vertex-shader wind sway on
  trees/awnings (touches other lanes' materials) — noted, not done. Tram stretch not attempted.

qa.sh --strict GREEN (5/5).

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

21 KiB
Raw Blame History

LANE B — NOTES (measured)

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 stateweatherFor(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)

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.jsno shell seam, so F only needs a flags-table row, no index.html wiring. Flag-OFF path is byte-identical to v1: windowQuad collapses to the v1 hoursQuad (no extra attributes) and the finalize uses the v1 windowMaterial() — verified: flag-off windows are MeshStandardMaterial with no aTangent; flag-on are the interior ShaderMaterial with aTangent. Seeded from shop.seed (deterministic).
  • Budget + fetches: flag-on worst continuous-walk view with citizens 122168 draws (≤300); the shader is 100% procedural — measured 0 fetch delta vs the ?noassets baseline (both 22 = the pre-existing facade/ground skin JPEGs). ?noassets=1&winmap=1 adds no network. 0 console errors in all modes. qa.sh --strict GREEN (4/4); GOLDEN.hash untouched (no plan change).

→ Fable (F2 flags table): ?winmap=1 — owner Lane B — state landed, default-off — needs no index.html wiring (self-read in buildings.js). Smoke: boot ?winmap=1, walk a block, glass shows rooms; night open=warm / closed=dark.


Round 4 (Fable's ROUND4 → Lane B) — done

  • B1 — closed-facade visuals (§3.5, the headline). Closed shops now render dark windows + a red CLOSED plate; open shops (esp. the one openLate video rental at night) render lit windows; the door tooltip reads 🔒 <name> — CLOSED · opens HH:00 on aim. Batched — the facade atlas is untouched: per-shop open/closed rides on a per-vertex aHours (vec2 = the shop's [open,close]) feeding TWO shared shaders (window emissive gated by open AND night; plate alpha gated by closed), driven by ONE pair of shared uniforms (uHour/uNight) updated once per day-segment change — no per-shop materials, no per-frame poll. lighting.js dispatches a procity:segment event (numeric hour matching Lane F's currentHour()); buildings.js listens and calls setFacadeHours. Verified live: 22:00 → only "Video Regal" lit, every other facade CLOSED; midday → all open, no plates; tooltip correct. Worst view with citizens: 200 total draws (≤300).
  • B2 — shot harness (gate-6). (a) Letterbox "fixed": the bars were not a composer bug (I proved the composer/renderer/camera are correctly 1280×720 in headless at every stage) — they were street_noon/arcade/warehouse_fringe poses jamming the camera against a big building wall. Reframed those bookmarks to look down a street (streetViewPose), so all 10 tour shots now fill the frame. Also added a defensive composer.setSize sync in DBG.render per F's request. (b) Added the 3 missing poses F listed (crossroads_busy, residential_collar, warehouse_fringe) — resolved from the live plan (busiest node / house-or-backstreets lot / outer-ring street), always a valid full-frame pose. night_neon now frames the open video shop glowing amid its CLOSED neighbours (the §3.5 money shot). tools/shots.py → 10 un-letterboxed shots, 0 console errors.
  • B3 — street-furniture GLB upgrade + novelty_record. furniture.js now upgrades primitives to depot GLBs via a synchronous use-if-ready check at chunk-build (no async write into a live chunk; ?noassets → primitives, 0 network verified). Placed the novelty_record landmark on the footpath in front of every record store (and a food_cart by market stalls / milk bars). ⚠ measured GLB tris: bench 1.9k ✓, food_cart 5.0k ✓, streetlight 5.0k ✓, but novelty_record is 26.5k (5× the ≤5k glb_law) — × many instances it spiked the busiest view to 574k tris. So novelty_record stays on its low-poly primitive (giant vinyl disc, budget-safe) until Lane E decimates the GLB — same treatment the ped rigs need (→ Lane E). With that, the bench GLB verified sitting on its exact footprint (0.44×1.21), tris back to ~25k.

bash tools/qa.sh --strict GREEN (4/4) after these changes. Round-4 files: dbg.js, buildings.js, lighting.js, hud.js, furniture.js (+ these docs, + docs/shots/laneB_r4/).


Streetscape + game shell. Files: web/index.html, web/js/world/*, web/js/world/fixture_plan.js. All numbers measured in-browser (Chromium, M-series) via renderer.info, 1280×720-ish viewport. renderer.info.autoReset is off and reset once/frame, so draws/tris are the true per-frame totals across all passes (scene + bloom + output). "scene draws" below excludes the ~11 post-processing passes; "total" adds them.

Perf — fixture (fixture_plan.js, "Yarraville Junction", 26 shops, radius 3, shadows on)

metric measured gate status
draw calls 148 scene (~159 total) ≤ 300
triangles ~5.8k ≤ 200k
skin materials (city-wide, shared) 21 ≤ 25
live chunks (radius 3) 12
chunk build ~18 ms 4 ms/frame budget drained 1/frame

Walked the fixture end-to-end (~310 m of main street + the backstreets): contiguous, no seams, chunks stream in/out silently (live count 8↔12 as you move; build queue never starved), day/night cycles through 6 segments, signs legible, door tooltips + procity:enterShop fire, collision stops the player at wallradius (measured 10.62 m against an 11 m frontage, radius 0.38). Runs with web/assets/ renamed away — no crashes, flat-colour fallback town, canvas signs still legible.

Perf — Lane A integration (generatePlan, "Boolarra Heads", ~493 shops, 1 km², radius 2 auto, shadows off)

Lane A's generatePlan landed mid-build; index.html prefers it over the fixture (guarded import), so this is the shipped default now. It is a full city with content in nearly every chunk — far denser than the fixture the ≤300 gate was sized against.

Streetscape numbers below are buildings + ground + furniture only (what Lane B owns), measured by a scripted continuous walk (real streaming + disposal, so ≤25 live chunks — no orphan-chunk inflation).

Round-3 re-measure WITH Lane D citizens (pop 140, the shipped default). Driving the real loop (chunks.update + citizens.update) along the dense corridors via window.DBG, up to 125 active peds at midday: worst continuous-walk view 263 scene / 275 total at MIDDAY and 275 total at NIGHT≤300 everywhere, ~25 draw margin. Peds are a batched impostor layer (~12 draws for the whole near tier), so they cost far less than their count. Radius-2 auto is already the spawn default for a big city; no config change was needed. (Fable's independent integrated read is ~191 on a typical main-street view.)

metric measured gate status
draw calls (worst gameplay view, continuous walk) ~261 total (249 scene) ≤ 300 ~40 draw margin
draw calls (typical street view) ~130210 total ≤ 300
triangles 12k25k ≤ 200k (huge headroom)
skin materials (shared, city-wide) 5 ≤ 25 (facade atlas collapsed ~22 facade mats → 1)
live chunks (radius 2, walking) ≤ 25
chunk build min ~1 / median ~8 / max ~22 ms 4 ms/frame ~1 heavy chunk/frame

Integration verified: Lane B streams and renders Lane A's 493-shop city correctly — facades, signs, doors, ground, furniture, day/night all work. Collision holds for the arbitrary lot orientations generatePlan emits (e.g. ry = 1.32 rad); see the collision fix below.

Adaptive settings (index.html)

BIG_CITY = plan.shops.length > 120. Big city → radius 2 + no sun-shadow pass; fixture → radius 3 + shadows. Override with ?r=N / ?shadows=0|1. Dropping the shadow pass ≈ halves draw calls in dense areas (the shadow map re-renders every caster) for little visual loss in a flat-lit low-poly town — LANE_B doc explicitly allows "none".

Draw-budget close-out (this session) — the gate now holds with margin

Fable's first integrated smoke test read ~334 draws at spawn (that was radius 3, pre-adaptive) and the deep districts appeared to peak ~445. Two things closed the gap:

  1. Facade texture atlas (skins.js + buildings.js). Every facade skin packs into one 2048² atlas (6×6 slots, each pre-painted with the skin's flat fallback colour, then the JPEG drawn over it — house law preserved), so a chunk's facades merge into one mesh (was ~58 per-skin meshes) and ~22 facade materials collapse to 1 (this also fixes the "≤25 materials was at the cap / over for some seeds" concern — now 5 city-wide). UV convention mirrors the sign atlas.
  2. Instanced-mesh merges. Awnings were one InstancedMesh per skin (red/green/blue) per chunk; now one per chunk with per-instance colour (the stripe JPEG lived on the awning's unseen top face, so flat colour reads the same under the verandah). Shells + parapets + posts merged into one InstancedMesh (per-instance colour) instead of two.

Measured effect (worst dense frustum): 393 → 265 scene draws; worst continuous-walk gameplay view ~261 total. Note the earlier "445" figures were partly a measurement artifact — teleport- jumping between distant spots left orphan chunks the real streamer disposes; a proper walk never exceeds the radius-2 set. Verified day + night, facades still textured, awning colours still varied.

Architecture decisions worth knowing (for Lane F)

  • Ground is built once, not streamed. The street graph is a handful of edges; merged flat quads are a few draws total and can't seam. If Lane A ships a many-hundred-edge network, revisit.
  • Sky/lighting are global, re-centred on the player each frame (dome radius 650, sun follows). Linear fog at (radius+0.4)·64 m doubles as the chunk pop-in mask.
  • Per-chunk streaming = buildings + furniture only. ChunkManager keeps chunks within Chebyshev radius R, disposes past R+1, drains a nearest-first build queue under a 4 ms/frame budget. A single dense chunk can exceed 4 ms (median 8), so it builds ~1/frame → an occasional ~822 ms build frame entering a dense district (fog-masked). Splitting a chunk build across frames is the next step if that hitch matters.
  • Night lighting is faked (Vuntra-style): window + streetlamp emissive toggled by a night flag, plus bloom — no per-streetlight real lights. ChunkManager.setNight() re-applies to chunks streamed in while already night.
  • scene.environment is set (round 3, lighting.attachRenderer): a neutral PMREM'd sky→ground gradient (no asset). Lane D's rig-fleet peds bake their impostor atlas from scene.environment (sim.js) and go dark if it's null; set here before CitizenSim constructs. One env for all segments (the atlas bakes once; the mostly-non-metal streetscape is barely affected). Verified set in street/night/interior + after interior-return (interior_mode only swaps scene.background).
  • Shared materials, city-wide (skins.js). Facades share one atlas material (all skins in a 2048² atlas); shells + parapets + posts are one InstancedMesh/chunk (per-instance colour); all awning/canopy skins are one InstancedMesh/chunk (per-instance colour). Signs are a per-chunk canvas atlas sized to the chunk's actual sign count, mipmaps off. Net: ~1 draw per kind per chunk.
  • Orientation law (obeyed by buildings.toWorld, the InstancedMesh shells, and pushOutRect): a lot at ry=0 faces +Z; local→world = RotY(ry). Collider and geometry share this convention.

Fixes applied after an adversarial multi-agent review (this session)

  • Collision for arbitrary ry (planutil.pushOutRect): world→local used the wrong rotation sign; correct only for axis-aligned ry. The fixture is axis-aligned so it passed, but Lane A's angled lots would let you walk through walls. Fixed to the true inverse of toWorld. (Critical for Lane A.)
  • House doors were merged into the interactive door mesh with no doorRects → raycasting a house door mis-resolved to the nearest shop. Now a separate non-picked mesh.
  • Awning skin index used a signed shift (seed >> 3) → negative index → undefined skin for ~⅓ of shops. Now >>> 3.
  • Sign atlas was a fixed 2048² (~16 MB canvas + ~22 MB GPU each) per shop-chunk regardless of sign count. Now sized to the actual sign count, mipmaps off — critical at Lane A's density.
  • Furniture edge hashing assumed string edge ids; generatePlan uses numeric ids → every edge got the same seed. Coerced with String(id).
  • Furniture drop: chunkIndex rasterised only the edge centreline, so furniture at a perpendicular offset could land in an unbuilt chunk and vanish. Rasterisation now covers the full road+verge band.
  • takeShots() never restored the pre-shot mode (P in map mode dumped you into street).

Reviewers' non-issues (intentional): getColliders returns a shared scratch array (per-frame alloc avoidance); per-frame {clock,chunks} HUD object (negligible).

The shot() harness

Press P → renders 3 fixed cameras and downloads PNGs. Reference stills captured to docs/shots/laneB/ (main-street day, plaza overview, night strip). Note: at Lane A's city the fixed camera poses are tuned for the fixture; re-aim if used against the generated city.

window.DBG — the QA harness hook (this session, for Lane F's shots.py/soak.py)

Loaded only with ?dbg=1 (js/world/dbg.js, installed by the shell). Answers LANE_F_NOTES §4. All poses are derived from the live plan so bookmarks work on any seed; the day cycle pauses on the first scripted segment change for deterministic captures.

  • DBG.ready — true once the first chunks are built + the queue is drained (textures may still decode).
  • DBG.shot(name) — snaps to a named bookmark, sets its time-of-day, settles bloom, returns stats. Bookmarks: street_noon, arcade (dept anchor), market_square (stalls), milkbar_dusk, night_neon.
  • DBG.teleport(x, z, ry) / DBG.setSegment(seg 05) — drive the soak walk + time of day.
  • DBG.enterShop(shopId) / DBG.exitShop() — scripted interior visits (drives Lane F's bridge → Lane C buildInterior; verified end-to-end: enterShop → mode interior, exitShop → back to street).
  • DBG.info(){ drawCalls, tris, fps, heapMB, geometries, textures, chunk, mode } (budget/soak read this).

Every bookmark stays under budget (busiest, the dept anchor, ~269 draws). fps reads the HUD's smoothed value (0 under a throttled/background tab; populates in a foreground Playwright run).

ChunkManager lifecycle hooks (optional, LANE_F_NOTES §8)

createChunkManager now fires ctx.onChunkBuilt(key, {cx, cz, buildings, furniture, data}) and ctx.onChunkDisposed(key, {cx, cz}) if a consumer sets them on ctx — a clean per-chunk seam for spawning/ambient/LOD. Inert by default (the shell doesn't set them; Lane D's citizens drive off plan.streets instead of chunk lifecycle, so this is a spare hook, not a dependency).

Controls

WASD move · shift run · mouse look · click a door · [ ] step time-of-day · T pause clock · M map · P screenshot · Esc release pointer.