PROCITY/web/js/world/wind.js
m3ultra ee16545941 Lane B round 17 (v3.2): wind sway SHIPS -- weather-driven gum-tree sway (ledger #5, parked since R7)
Verdict: shipped, not killed. The R7 park reason ("touches shared materials") is a non-blocker under
R16's measurement-over-brief standard: the gum-tree billboard material is B-owned (furniture.js), so
this is entirely Lane B -- no C/E material seam. Verified fresh Chromium seed 20261990.

- New web/js/world/wind.js: shared uniforms (uWindTime/uWindAmp) + applyWind(material) (onBeforeCompile
  vertex patch: top-weighted horizontal sway, per-instance world-position phase, USE_INSTANCING-guarded)
  + updateWind(dt,intensity). furniture.js applies it to the tree material; weather.js drives it from
  update(dt) (base breeze 0.05 clear -> 0.05+intensity*0.30 ~0.35 rain gusts).
- Trees only. Awnings excluded by MEASUREMENT: rigid posted-verandah box slabs don't flutter (fabric
  canopy ripple = separate v3.3, shares the rigid awning InstancedMesh today).
- Byte-identical when weather off (the covenant): updateWind runs only inside weather.update, and the
  weather system isn't constructed under ?weather=0 / ?classic=1 -> uWindAmp stays 0 -> sin(...)*0.0==0.0
  -> identical vertices. Proven: amp=0 two times = 0/180000 px differ; ?weather=0 boot = 0 tree-motion px;
  ?classic=1 = amp 0, trees render, 0 errors. No plan/fetch/rng change -> F's classic gate untouched.
- Sway works: rain amp 0.305 moves ~14100/180000 region px over 0.8s (exaggerated 2.5 -> 101k); trees
  render clean, no glitch. ~0 draws (ALU ops in the existing tree InstancedMesh vertex program), 0 errors.

-> Lane F (B->F handshake): SWAY SHIPPED. In old frames under the default boot; ?classic=1/?weather=0
   sway-free + byte-identical. Procedural (no asset -> E has nothing to do).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 14:30:09 +10:00

56 lines
3.2 KiB
JavaScript

// 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 <begin_vertex>',
[
'#include <begin_vertex>',
'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
}