diff --git a/docs/LANES/LANE_B_NOTES.md b/docs/LANES/LANE_B_NOTES.md index 573253b..4bd89fc 100644 --- a/docs/LANES/LANE_B_NOTES.md +++ b/docs/LANES/LANE_B_NOTES.md @@ -1,5 +1,46 @@ # LANE B — NOTES (measured) +## Round 17 (Fable's ROUND17 → Lane B) — wind sway SHIPS (ledger #5, parked since R7) + +**Verdict: SHIPPED, not killed.** The R7 park reason ("touches shared materials") was tested against +R16's measurement-over-brief standard and is a **non-blocker**: the gum-tree billboard material lives +in `furniture.js` (Lane B), so wind sway is **entirely Lane B — no C/E material seam**. New file +`web/js/world/wind.js` + two small hookups (`furniture.js` applies it, `weather.js` drives it). + +### What ships +- **`wind.js`** — one shared uniform set (`uWindTime`, `uWindAmp`) + `applyWind(material)` (an + `onBeforeCompile` vertex patch: top-weighted horizontal sway, per-instance phase from world position + so neighbours desync, `#ifdef USE_INSTANCING`-guarded) + `updateWind(dt, intensity)`. +- **Trees only.** Gum-tree canopies sway. **Awnings are excluded — a *measured* call, not a punt:** + they're rigid posted-verandah box slabs (`buildings.js`), and a structural verandah roof doesn't + flutter. (Fabric-canopy ripple would be a separate v3.3 item — the striped canopies share the rigid + awning InstancedMesh today, so splitting them is its own task. Not needed for "wind sway ships.") +- **Weather-driven:** `updateWind` is called only from `weather.update(dt)` → base breeze on any + weather-on boot (`amp 0.05`, clear included) scaling to rain gusts (`0.05 + intensity·0.30 ≈ 0.35`). + +### Byte-identical when weather is off (the covenant) +`updateWind` runs **only** inside `weather.update`, and the weather *system* is not constructed under +`?weather=0` or `?classic=1` — so `uWindAmp` stays at its init **0**, and the injected displacement is +`sin(...)·0.0·h == 0.0` → `transformed += 0.0` → identical vertices → identical pixels. Verified: +- `amp=0`, two different `uWindTime` values → **0 / 180,000 region pixels differ**. +- `?weather=0` boot → `uWindAmp 0`, advancing time → **0 tree-motion pixels**. +- `?classic=1` boot → `uWindAmp 0`, trees render, **0 console errors** (shader patched but inert). +- No plan change, no fetch, no rng (time-based, not seeded) — F's classic gate (`0x3fa36874` + fetch + delta) is untouched; the sway is pure render. + +### Sway works + budget +- Realistic **rain amp 0.305**: advancing 0.8 s moves **~14,100 / 180,000** region pixels (clear + motion); exaggerated amp 2.5 moves 101k. Trees render normally with the shader (verified the + side-street verge — natural canopies, no glitch/explosion). +- **~0 draws** (a handful of ALU ops in the tree InstancedMesh's existing vertex program — no new + geometry, no new draw). **0 console errors** (GLSL compiles clean). + +**→ Lane F (B→F handshake): SWAY SHIPPED.** It's in old frames now under the default boot (weather-on). +`?classic=1` / `?weather=0` are sway-free and byte-identical (proven above). README `?weather` note, if +you grow one, can mention "trees sway with the wind." No new asset (procedural), so E has nothing to do. + +--- + ## Round 16 (Fable's ROUND16 → Lane B) — v3.1 THE FLIP: town-wide pools + default-boot re-baseline Two deliverables. Verified fresh Chromium, seed 20261990. (Ran before F's flip landed — my pools are diff --git a/web/js/world/furniture.js b/web/js/world/furniture.js index 7406e56..fa76e03 100644 --- a/web/js/world/furniture.js +++ b/web/js/world/furniture.js @@ -13,6 +13,7 @@ import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; import { loadGLB } from '../core/loaders.js'; import { rng, frange } from '../core/prng.js'; import { chunkOf, resolveEdges } from './planutil.js'; +import { applyWind } from './wind.js'; // weather-driven vertex sway on the gum-tree billboards (R17) const FOOT = 3.5; // bus-shelter footprint (from manifest: 2.33 × 1.41 m) + a facing tweak applied to the along-edge @@ -145,6 +146,7 @@ function materials() { novelty: new THREE.MeshStandardMaterial({ color: 0x1a1a1a, roughness: 0.35, metalness: 0.1 }), // vinyl foodcart: new THREE.MeshStandardMaterial({ color: 0x9a3b32, roughness: 0.7 }), }; + applyWind(_mats.tree, { topY: 4.4 }); // gum-tree canopy sways when weather is on (amp 0 ⇒ byte-identical off) return _mats; } diff --git a/web/js/world/weather.js b/web/js/world/weather.js index 5177c36..f44e8a9 100644 --- a/web/js/world/weather.js +++ b/web/js/world/weather.js @@ -10,6 +10,7 @@ import * as THREE from 'three'; import { rng, frange, pick } from '../core/prng.js'; +import { updateWind } from './wind.js'; // R17: weather drives the tree-sway uniform (off ⇒ never called ⇒ calm) const RAIN_SKIES = ['summer-storm', 'monsoon', 'cold-front']; const OVERCAST_SKIES = ['grey-drizzle', 'cold-front', 'high-cirrus']; @@ -113,6 +114,7 @@ export function createWeather({ scene, plan, skins, lighting, camera, force = nu function update(dt) { group.position.copy(camera.position); // the rain box follows the player if (rainMat) rainMat.uniforms.uTime.value += dt; + updateWind(dt, state.intensity); // sway the gum trees — calm breeze → rain gusts (weather-on only) } function dispose() { diff --git a/web/js/world/wind.js b/web/js/world/wind.js new file mode 100644 index 0000000..c20448d --- /dev/null +++ b/web/js/world/wind.js @@ -0,0 +1,55 @@ +// PROCITY Lane B — wind.js (round 17, v3.2 — "wind sway ships or dies") +// Weather-driven vertex-shader sway for street foliage. Parked since R7 with the note "touches shared +// materials"; the R16 standard is measurement-over-brief, and the measurement here is: the gum-tree +// material is B-owned street geometry (furniture.js), so this is ENTIRELY Lane B — no C/E material seam. +// +// ONE shared uniform set, referenced by the tree material via applyWind(). Amplitude stays 0 until the +// weather system drives it (updateWind, called from weather.update). With weather OFF — ?weather=0, or +// ?classic=1 where weather is never constructed — updateWind never runs, uWindAmp stays 0, the injected +// displacement is exactly `... * 0.0 == 0.0`, and trees render byte-identical to no-sway (verified). So +// the classic covenant and ?weather=0 both hold by construction. +// +// SCOPE (measured, per R16 discipline): gum-tree BILLBOARDS only. Awnings are rigid posted-verandah box +// slabs (buildings.js) — a structural verandah roof does not flutter, so swaying it would read wrong; it +// is intentionally excluded (not a cross-lane blocker — a physical one). Fabric-canopy flutter, if ever +// wanted, is a separate v3.3 item (the striped canopies share the rigid awning InstancedMesh today). +// +// Cost: ~0 draws — a handful of ALU ops added to the tree InstancedMesh's existing vertex program. + +export const windUniforms = { + uWindTime: { value: 0 }, + uWindAmp: { value: 0 }, // 0 = dead calm ⇒ zero displacement ⇒ byte-identical (weather off / classic) +}; + +// Patch a material's vertex program: sway top vertices horizontally (top-weighted, base planted). A +// per-instance phase from world position desyncs neighbouring trees. Guarded for instanced use so the +// same material would still compile if ever drawn non-instanced. +export function applyWind(material, { topY = 4.4 } = {}) { + material.onBeforeCompile = (shader) => { + shader.uniforms.uWindTime = windUniforms.uWindTime; + shader.uniforms.uWindAmp = windUniforms.uWindAmp; + shader.vertexShader = 'uniform float uWindTime;\nuniform float uWindAmp;\n' + shader.vertexShader; + shader.vertexShader = shader.vertexShader.replace( + '#include ', + [ + '#include ', + 'float wPhase = position.x * 0.1 + position.z * 0.1;', + '#ifdef USE_INSTANCING', + ' wPhase = instanceMatrix[3].x * 0.15 + instanceMatrix[3].z * 0.15;', + '#endif', + `float wH = clamp(position.y / ${topY.toFixed(1)}, 0.0, 1.0); wH *= wH;`, // top-weighted, base planted + 'float wS = sin(uWindTime + wPhase) + 0.4 * sin(uWindTime * 2.3 + wPhase * 1.7);', + 'transformed.x += wS * uWindAmp * wH;', + 'transformed.z += cos(uWindTime * 0.8 + wPhase) * uWindAmp * wH * 0.5;', + ].join('\n') + ); + }; + material.needsUpdate = true; +} + +// Drive from weather.update(dt): base breeze on any weather-on boot (clear included), gusts scaling with +// rain intensity. NEVER called when weather is absent ⇒ uWindAmp stays 0 (byte-identical, see header). +export function updateWind(dt, intensity = 0) { + windUniforms.uWindTime.value += dt * 1.6; + windUniforms.uWindAmp.value = 0.05 + Math.max(0, intensity) * 0.30; // calm ~0.05 m top sway → gusty ~0.35 m +}