// 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) }; // ── R38 §1.4: THE WEIGHT IS UPSIDE DOWN FOR ANYTHING PEGGED AT THE TOP ────────────────────────── // The `canopy` weight below is `wH = clamp(y/topY,0,1); wH *= wH` — top-weighted, base planted. // That is correct for a gum and **exactly wrong for a sheet on a Hills Hoist**, which is pegged at // the top and free at the hem. Measured on the shipped numbers: a sheet hanging 0.6–1.6 m under the // canopy weight gets wH = (1.6/4.4)² = 0.132 at its TOP and 0.019 at its hem, so with the shipped // amplitude (`updateWind`: 0.05 calm → 0.35 gusty) the free end of the washing moves // **0.10 cm calm / 0.67 cm gusty** and the pegged end moves the most — visually inert, and inverted. // The `pegged` mode weights by distance BELOW the peg line instead: wH = 1 at the hem, 0 at the // pegs ⇒ **5.0 cm calm / 35 cm gusty at the hem, a 52× gain**, and the top stays put because that // is where the pegs are. It also flutters faster and adds a Z-flap, because a sheet is a sail and a // gum is a spring. (Fable's R38 binding correction 6, taken as the "write the mode" branch.) // // `wing` (§1.3, the magpie) is the third weight: |x| from the body centreline, displacement in Y, // so a wing beats and a body does not. Still on uWindAmp ⇒ still exactly 0 at dead calm. // // **The `canopy` string is byte-for-byte what it was pre-R38**, so the gum-tree program compiles to // the same source it always has and the covenant argument for trees is unchanged. const MODES = { canopy: (topY) => [ `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;', ], // Pegged at the TOP of the local geometry (y ∈ [0, topY], pegs at topY): free at the hem. pegged: (topY) => [ `float wH = clamp((${topY.toFixed(2)} - position.y) / ${topY.toFixed(2)}, 0.0, 1.0); wH *= wH;`, 'float wS = sin(uWindTime * 2.1 + wPhase) + 0.5 * sin(uWindTime * 4.7 + wPhase * 2.3);', 'transformed.x += wS * uWindAmp * wH;', 'transformed.z += sin(uWindTime * 3.3 + wPhase * 1.3) * uWindAmp * wH * 0.75;', 'transformed.y -= abs(wS) * uWindAmp * wH * 0.12;', // a lifting sheet also shortens ], // Wing beat: weighted by span from the body centreline, displaced in Y. wing: (topY) => [ `float wH = clamp(abs(position.x) / ${topY.toFixed(2)}, 0.0, 1.0);`, 'float wS = sin(uWindTime * 9.0 + wPhase);', 'transformed.y += wS * uWindAmp * wH * 3.0;', 'transformed.x -= abs(wS) * uWindAmp * wH * 0.35 * sign(position.x);', ], }; // Patch a material's vertex program: displace vertices by a mode-specific weight × the shared // amplitude. A per-instance phase from world position desyncs neighbours. Guarded for instanced use // so the same material would still compile if ever drawn non-instanced. // `topY` is the mode's reference length: canopy → tree height, pegged → drop below the pegs, // wing → half-span. export function applyWind(material, { topY = 4.4, mode = 'canopy' } = {}) { const body = (MODES[mode] || MODES.canopy)(topY); 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', ...body, ].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 }