// 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 }