'use strict'; // SHADES — Lane C — skyfx: rain, storm sky, lightning, and the noise of it all. // // PLAN3D §5-C.2/§5-C.3. Lane A's world.js owns the calm sky and the base lights; // this MODULATES them as the storm builds and hands them back on dispose(), so // two lanes never fight over one scene. // // Everything is duck-typed and optional — no sun light, no audio, no sail? Then // those layers just don't run. Lane A can wire the pieces as they land. // // Audio is synthesized, not sampled: web/world/audio/ is empty, we ship no CDN // and no deps, and a filtered-noise bed tracks wind speed better than a loop. import * as THREE from '../vendor/three.module.js'; import { rng } from './contracts.js'; import { valueNoise2 } from './weather.core.js'; const lerp = (a, b, k) => a + (b - a) * k; const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v); const CALM_SKY = new THREE.Color(0x9fc4e8); const STORM_SKY = new THREE.Color(0x2a2f3a); const NIGHT_SKY = new THREE.Color(0x11141c); // ------------------------------------------------------------ rain shadow /** * Where the sail is keeping the ground dry (SPRINT2 §Lane C.3). * * This is the RAIN shadow, not the sun shadow. Rain arrives along the wind, so * the dry patch sits downwind of the cloth and slides across the yard as the * wind swings — at the southerly change it walks right off the garden, which is * free drama and the honest physics. * * Cheap on purpose: ray-testing 3 k drops against 162 triangles every frame is * ~486 k intersections for an effect nobody inspects closely. Instead we project * the sail's triangles ALONG the rain onto the ground and rasterise them into a * coarse grid, a few times a second — the cloth moves slowly next to the rain. * Per-drop cost is then one projection and one array read. * * Reads `rig.pos`/`rig.tris`, which are already the surface Lane A's sail view * consumes, so this needs nothing new from Lane B. */ export class RainShadow { constructor(o = {}) { this.n = o.cells ?? 64; // ~0.56 m over a 36 m span this.half = o.half ?? 18; this.groundY = o.groundY ?? 0; this.ceil = new Float32Array(this.n * this.n); // sail height per cell, 0 = open sky this.live = false; this.dx = 0; this.dy = -1; this.dz = 0; } _idx(gx, gz) { const i = Math.floor(((gx + this.half) / (this.half * 2)) * this.n); const j = Math.floor(((gz + this.half) / (this.half * 2)) * this.n); if (i < 0 || j < 0 || i >= this.n || j >= this.n) return -1; return j * this.n + i; } /** @param {object} rig Lane B's SailRig @param {number} dx,dy,dz unit rain direction */ update(rig, dx, dy, dz) { this.live = false; if (!rig || !rig.pos || !rig.tris || dy > -1e-3) return; // rain must fall this.ceil.fill(0); this.dx = dx; this.dy = dy; this.dz = dz; const pos = rig.pos, tris = rig.tris, cellW = (this.half * 2) / this.n; const gx = [0, 0, 0], gz = [0, 0, 0], gy = [0, 0, 0]; for (let i = 0; i < tris.length; i += 3) { for (let k = 0; k < 3; k++) { const a = tris[i + k] * 3; const vy = pos[a + 1]; const tt = (vy - this.groundY) / -dy; // slide down the rain to the ground gx[k] = pos[a] + dx * tt; gz[k] = pos[a + 2] + dz * tt; gy[k] = vy; } const d = (gz[1] - gz[2]) * (gx[0] - gx[2]) + (gx[2] - gx[1]) * (gz[0] - gz[2]); if (Math.abs(d) < 1e-9) continue; // degenerate once projected const minX = Math.min(gx[0], gx[1], gx[2]), maxX = Math.max(gx[0], gx[1], gx[2]); const minZ = Math.min(gz[0], gz[1], gz[2]), maxZ = Math.max(gz[0], gz[1], gz[2]); for (let px = minX; px <= maxX + cellW; px += cellW) { for (let pz = minZ; pz <= maxZ + cellW; pz += cellW) { const c = this._idx(px, pz); if (c < 0) continue; // barycentric, with a little slop so cracks between tris don't leak rain const l1 = ((gz[1] - gz[2]) * (px - gx[2]) + (gx[2] - gx[1]) * (pz - gz[2])) / d; const l2 = ((gz[2] - gz[0]) * (px - gx[2]) + (gx[0] - gx[2]) * (pz - gz[2])) / d; const l3 = 1 - l1 - l2; if (l1 < -0.05 || l2 < -0.05 || l3 < -0.05) continue; const y = l1 * gy[0] + l2 * gy[1] + l3 * gy[2]; if (y > this.ceil[c]) this.ceil[c] = y; } } this.live = true; } } /** Has a drop here already been stopped by the cloth? */ occluded(x, y, z) { if (!this.live) return false; const tt = (y - this.groundY) / -this.dy; const c = this._idx(x + this.dx * tt, z + this.dz * tt); if (c < 0) return false; const ceil = this.ceil[c]; return ceil > 0 && y < ceil; // above the cloth it hasn't hit yet } /** 0..1 of a ground rect under cover. Same rect shape as sailRig.coverageOver. */ fractionOver(rect, cols = 6, rows = 4) { if (!this.live) return 0; let hit = 0; for (let i = 0; i < cols; i++) { for (let j = 0; j < rows; j++) { const x = rect.x + ((i + 0.5) / cols - 0.5) * rect.w; const z = rect.z + ((j + 0.5) / rows - 0.5) * rect.d; const c = this._idx(x, z); if (c >= 0 && this.ceil[c] > 0) hit++; } } return hit / (cols * rows); } } /** Rain velocity, m/s. One definition, used by the drops and by the shadow. */ function rainVelocity(w, intensity, out) { return out.set(w.x * 0.55, -(9 + intensity * 4), w.z * 0.55); } // ---------------------------------------------------------------- rain function createRain(opts) { const max = opts.maxDrops ?? 3000; const half = opts.half ?? 18; // box half-extent around the camera const height = opts.height ?? 24; const groundY = opts.groundY ?? 0; const rand = rng(0xd309); // one thin quadish streak, instanced — cheap and reads as rain in motion const geo = new THREE.BoxGeometry(0.015, 1, 0.015); const mat = new THREE.MeshBasicMaterial({ color: 0xb4d2ff, transparent: true, opacity: 0.34, depthWrite: false, fog: false, }); const mesh = new THREE.InstancedMesh(geo, mat, max); mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); mesh.frustumCulled = false; mesh.renderOrder = 2; mesh.count = 0; const px = new Float32Array(max), py = new Float32Array(max), pz = new Float32Array(max); const jitter = new Float32Array(max); for (let i = 0; i < max; i++) { px[i] = (rand() * 2 - 1) * half; py[i] = groundY + rand() * height; pz[i] = (rand() * 2 - 1) * half; jitter[i] = 0.75 + rand() * 0.5; // not every drop is the same drop } const m = new THREE.Matrix4(); const q = new THREE.Quaternion(); const up = new THREE.Vector3(0, 1, 0); const vel = new THREE.Vector3(); const unit = new THREE.Vector3(); const scale = new THREE.Vector3(1, 1, 1); const zero = new THREE.Vector3(); // zero-scale: an instance that renders to nothing const HIDDEN = new THREE.Matrix4().makeScale(0, 0, 0); return { mesh, /** * @param {THREE.Vector3} camPos * @param {THREE.Vector3} w local wind * @param {RainShadow} [shadow] drops under the cloth are not drawn */ step(dt, camPos, w, intensity, shadow) { const n = Math.floor(max * clamp01(intensity)); mesh.count = n; if (n === 0) return; // rain leans into the wind; that lean IS the readout of how hard it's blowing rainVelocity(w, intensity, vel); const fall = -vel.y; const speed = vel.length() || 1; q.setFromUnitVectors(up, unit.copy(vel).divideScalar(speed)); // streak stretches with speed — drizzle is dots, a squall is lines scale.set(1, Math.min(2.6, 0.35 + speed * 0.055), 1); m.compose(zero, q, scale); const top = groundY + height; for (let i = 0; i < n; i++) { const j = jitter[i]; px[i] += w.x * 0.55 * j * dt; py[i] -= fall * j * dt; pz[i] += w.z * 0.55 * j * dt; // wrap the box around the camera instead of respawning — no bookkeeping, // and the rain is always exactly where the player is looking let d = px[i] - camPos.x; if (d > half) px[i] -= half * 2; else if (d < -half) px[i] += half * 2; d = pz[i] - camPos.z; if (d > half) pz[i] -= half * 2; else if (d < -half) pz[i] += half * 2; if (py[i] < groundY) py[i] += height; else if (py[i] > top) py[i] -= height; // Under the cloth this drop was stopped up there. Keep simulating it — // it wraps back to the top and rains again beyond the sail's edge — but // don't draw it. A degenerate matrix is cheaper than reshuffling the // instance list, and InstancedMesh has no per-instance visibility. if (shadow && shadow.occluded(px[i], py[i], pz[i])) { mesh.setMatrixAt(i, HIDDEN); continue; } m.elements[12] = px[i]; m.elements[13] = py[i]; m.elements[14] = pz[i]; mesh.setMatrixAt(i, m); } mesh.instanceMatrix.needsUpdate = true; }, dispose() { geo.dispose(); mat.dispose(); }, }; } // ------------------------------------------------------------ cloud dome function cloudTexture(size = 256, seed = 7) { const cv = document.createElement('canvas'); cv.width = cv.height = size; const ctx = cv.getContext('2d'); const img = ctx.createImageData(size, size); for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { // fbm at integer frequencies, each octave wrapped at its own period, so // the texture tiles: it's set to repeat(3,2) and it scrolls forever, and // an unwrapped octave puts a dead straight seam across the sky. let n = 0, amp = 0.5, f = 4; for (let o = 0; o < 4; o++) { n += amp * valueNoise2((x / size) * f, (y / size) * f, seed + o * 977, f); amp *= 0.5; f *= 2; } const v = clamp01((n - 0.28) * 2.2); const i = (y * size + x) * 4; const shade = 150 + v * 70; img.data[i] = shade; img.data[i + 1] = shade; img.data[i + 2] = shade + 12; img.data[i + 3] = v * 235; } } ctx.putImageData(img, 0, 0); const tex = new THREE.CanvasTexture(cv); tex.wrapS = tex.wrapT = THREE.RepeatWrapping; tex.repeat.set(3, 2); return tex; } // ---------------------------------------------------------------- audio // Synthesized layers. WebAudio won't start until a gesture (browser rule), so // everything is built lazily on unlock() and silently absent before it. function createAudio(seed = 1) { let ctx = null, master = null; let windGain, windFilter, windHowl, howlGain; let rainGain, rainFilter; let gustGain, gustFilter; let noiseBuf = null; let creakNext = 0, flogNext = 0; let started = false; function noiseBuffer(c) { const len = c.sampleRate * 4; const buf = c.createBuffer(1, len, c.sampleRate); const d = buf.getChannelData(0); const rand = rng(seed ^ 0x0157); let last = 0; for (let i = 0; i < len; i++) { const white = rand() * 2 - 1; last = (last + 0.02 * white) / 1.02; // brown-ish: weight to the low end d[i] = last * 3.5; } return buf; } function loop(buf, dest, filter) { const src = ctx.createBufferSource(); src.buffer = buf; src.loop = true; src.connect(filter); filter.connect(dest); src.start(); return src; } return { get ready() { return started; }, /** 'running' | 'suspended' | 'closed' | 'none'. `ready` only means the graph * got built — a suspended context is still silent, so the HUD reports this. */ get state() { return ctx ? ctx.state : 'none'; }, /** Current layer gains — for the HUD and for asserting the bed tracks wind. */ levels() { if (!started) return null; return { wind: +windGain.gain.value.toFixed(4), howl: +howlGain.gain.value.toFixed(4), rain: +rainGain.gain.value.toFixed(4), cutoff: Math.round(windFilter.frequency.value), }; }, /** Call from the first click/keydown. Safe to call repeatedly. */ unlock() { if (started) return; const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return; ctx = new AC(); if (ctx.state === 'suspended') ctx.resume(); master = ctx.createGain(); master.gain.value = 0.55; master.connect(ctx.destination); noiseBuf = noiseBuffer(ctx); // wind bed: brown noise through a lowpass that opens as it blows harder windGain = ctx.createGain(); windGain.gain.value = 0; windFilter = ctx.createBiquadFilter(); windFilter.type = 'lowpass'; windFilter.frequency.value = 400; windGain.connect(master); loop(noiseBuf, windGain, windFilter); // howl: a resonant band on top — this is the bit that sounds like a gale howlGain = ctx.createGain(); howlGain.gain.value = 0; windHowl = ctx.createBiquadFilter(); windHowl.type = 'bandpass'; windHowl.frequency.value = 500; windHowl.Q.value = 6; howlGain.connect(master); loop(noiseBuf, howlGain, windHowl); rainGain = ctx.createGain(); rainGain.gain.value = 0; rainFilter = ctx.createBiquadFilter(); rainFilter.type = 'highpass'; rainFilter.frequency.value = 1800; rainGain.connect(master); loop(noiseBuf, rainGain, rainFilter); gustGain = ctx.createGain(); gustGain.gain.value = 0; gustFilter = ctx.createBiquadFilter(); gustFilter.type = 'bandpass'; gustFilter.frequency.value = 300; gustFilter.Q.value = 2.5; gustGain.connect(master); loop(noiseBuf, gustGain, gustFilter); started = true; }, /** One-shot filtered noise burst — the workhorse for creak/flog/thunder. */ burst({ freq, q, gain, attack, decay, type = 'bandpass' }) { if (!started) return; const now = ctx.currentTime; const src = ctx.createBufferSource(); src.buffer = noiseBuf; src.loop = true; const f = ctx.createBiquadFilter(); f.type = type; f.frequency.value = freq; f.Q.value = q ?? 4; const g = ctx.createGain(); g.gain.setValueAtTime(0.0001, now); g.gain.exponentialRampToValueAtTime(Math.max(0.0002, gain), now + attack); g.gain.exponentialRampToValueAtTime(0.0001, now + attack + decay); src.connect(f); f.connect(g); g.connect(master); src.start(now); src.stop(now + attack + decay + 0.05); }, /** @param {number} speed m/s @param {number} rain 0..1 */ setLevels(speed, rain) { if (!started) return; const now = ctx.currentTime; const s = clamp01(speed / 32); // gain and brightness both climb — a 30 m/s wind isn't just a louder 5 m/s one windGain.gain.setTargetAtTime(0.05 + s * 0.5, now, 0.15); windFilter.frequency.setTargetAtTime(220 + s * 900, now, 0.2); howlGain.gain.setTargetAtTime(s * s * 0.28, now, 0.2); windHowl.frequency.setTargetAtTime(320 + s * 700, now, 0.25); rainGain.gain.setTargetAtTime(rain * 0.34, now, 0.3); rainFilter.frequency.setTargetAtTime(1500 + rain * 900, now, 0.3); }, /** Telegraph cue: you hear it coming before you feel it. */ whoosh(power, eta) { if (!started) return; const now = ctx.currentTime; const p = clamp01(power / 18); gustGain.gain.cancelScheduledValues(now); gustGain.gain.setValueAtTime(gustGain.gain.value, now); gustGain.gain.linearRampToValueAtTime(0.05 + p * 0.3, now + Math.max(0.05, eta)); gustGain.gain.linearRampToValueAtTime(0.0001, now + Math.max(0.05, eta) + 2.2); gustFilter.frequency.cancelScheduledValues(now); gustFilter.frequency.setValueAtTime(220, now); gustFilter.frequency.linearRampToValueAtTime(240 + p * 700, now + Math.max(0.05, eta) + 0.8); }, /** Rope creak — rate and pitch both ride the worst corner. */ creak(dt, loadFrac) { if (!started || loadFrac < 0.35) return; creakNext -= dt; if (creakNext > 0) return; creakNext = lerp(1.1, 0.16, clamp01((loadFrac - 0.35) / 0.65)); this.burst({ freq: 180 + loadFrac * 420, q: 9, gain: 0.05 + loadFrac * 0.22, attack: 0.012, decay: 0.16, }); }, /** Freed corner: canvas cracking itself to pieces. */ flog(dt, speed) { if (!started) return; flogNext -= dt; if (flogNext > 0) return; flogNext = Math.max(0.09, 0.5 - speed * 0.011); this.burst({ freq: 900 + speed * 26, q: 1.2, gain: 0.1 + clamp01(speed / 30) * 0.3, attack: 0.005, decay: 0.1 }); }, thunder(power) { if (!started) return; this.burst({ type: 'lowpass', freq: 90 + power * 60, q: 0.7, gain: 0.25 + power * 0.5, attack: 0.06, decay: 2.6 + power * 1.6 }); }, dispose() { if (ctx) ctx.close(); started = false; }, }; } // ---------------------------------------------------------------- skyfx /** * @param {object} o * @param {THREE.Scene} o.scene * @param {THREE.Camera} o.camera * @param {object} o.wind from weather.js * @param {THREE.Light} [o.sun] Lane A's directional light — we dim it * @param {THREE.Light} [o.hemi] Lane A's hemisphere light * @param {boolean} [o.night] storm_02 is a wild NIGHT * @param {function} [o.onEvent] (text) HUD ticker */ export function createSkyFx(o = {}) { const { scene, camera, wind } = o; const sun = o.sun || null; const hemi = o.hemi || null; const def = (wind && wind.def) || {}; const skyDef = def.sky || {}; const darkness = skyDef.darkness ?? 0.7; const scroll = skyDef.cloudScroll ?? 0.06; const target = (o.night ?? darkness > 0.6) ? NIGHT_SKY : STORM_SKY; const rain = createRain({ groundY: o.groundY ?? 0 }); if (scene) scene.add(rain.mesh); const shadow = new RainShadow({ groundY: o.groundY ?? 0 }); const rainDir = new THREE.Vector3(); let shadowTick = 0; const audio = createAudio((wind && wind.seed) || 1); // cloud dome rides the camera so it can't clip the far plane whatever Lane A set const domeTex = cloudTexture(256, ((wind && wind.seed) || 7) & 0xffff); const dome = new THREE.Mesh( new THREE.SphereGeometry(180, 24, 16), new THREE.MeshBasicMaterial({ map: domeTex, side: THREE.BackSide, transparent: true, depthWrite: false, fog: false, opacity: 0, }), ); dome.renderOrder = -1; if (scene) scene.add(dome); // Remember what world.js handed us, so dispose() puts it back exactly. // Fog is captured BY VALUE, not by reference: step() mutates that very object // in place, so `scene.fog = original.fog` restores the object we just spent a // storm wrecking. Lane A caught it — sun and hemi came back exactly and the fog // stayed where the storm left it. Harmless today only because the next skyfx // immediately re-drives it, which is exactly the kind of bug that waits. const ownsFog = !!scene && !scene.fog; const original = { background: scene ? scene.background : null, fog: scene ? scene.fog : null, fogColor: scene && scene.fog ? scene.fog.color.clone() : null, fogNear: scene && scene.fog ? scene.fog.near : 0, fogFar: scene && scene.fog ? scene.fog.far : 0, sun: sun ? sun.intensity : 0, hemi: hemi ? hemi.intensity : 0, }; const baseSky = (scene && scene.background && scene.background.isColor) ? scene.background.clone() : CALM_SKY.clone(); const skyCol = baseSky.clone(); if (scene) { scene.background = skyCol; if (!scene.fog) scene.fog = new THREE.Fog(skyCol.getHex(), 30, 140); } let flash = 0; // decaying lightning brightness let flashQueue = []; // {at, power} — double-strike let lastTelegraph = null; const camPos = new THREE.Vector3(); const w = new THREE.Vector3(); const fx = { rain, audio, dome, shadow, get flash() { return flash; }, /** * 0..1 of a ground rect the sail is keeping dry, right now. * * Lane A: this is NOT `rig.coverageOver(bed, world.sunDir)`. That one is the * SUN shadow — the summer-afternoon question. This is the RAIN shadow, which * arrives along the wind, sits downwind of the cloth, and walks across the * yard when the wind swings. During a storm at night the sun shadow is a * number about nothing; this is the one that says whether the garden is * getting hit. Which of the two drives garden HP is a design call, not mine — * flagged in THREADS. Cheap either way: reads the grid we already built. */ rainShadowOver(rect) { return shadow.fractionOver(rect); }, /** Wire to the first click/keydown — browsers won't start audio otherwise. */ unlockAudio() { audio.unlock(); }, /** * @param {number} dt * @param {number} t storm time * @param {object} [world] {sail} — duck-typed, for creak/flog */ step(dt, t, world = {}) { if (!camera) return; camera.getWorldPosition(camPos); wind.sample(camPos, t, w); const speed = Math.hypot(w.x, w.z); const intensity = wind.rainAt(t); const storminess = clamp01(Math.max(intensity, speed / 26)); // --- events: lightning + the ticker --- for (const ev of wind.eventsBetween(t - dt, t)) { if (ev.type === 'lightning') { const p = ev.power ?? 0.7; flashQueue.push({ at: t, power: p }); flashQueue.push({ at: t + 0.09 + p * 0.07, power: p * 0.55 }); // the stutter // thunder lags the flash — distance you can hear const delay = (ev.distance ?? 1.2) * 0.9; flashQueue.push({ at: t + delay, power: 0, thunder: p }); } else if (ev.type === 'windchange' && ev.text && o.onEvent) { o.onEvent(ev.text); } } for (let i = flashQueue.length - 1; i >= 0; i--) { if (flashQueue[i].at <= t) { const f = flashQueue[i]; if (f.thunder) audio.thunder(f.thunder); else flash = Math.max(flash, f.power); flashQueue.splice(i, 1); } } flash *= Math.max(0, 1 - dt * 7); if (flash < 0.004) flash = 0; // --- sky --- skyCol.copy(baseSky).lerp(target, storminess * darkness); if (flash > 0) skyCol.lerp(new THREE.Color(0xdfe8ff), Math.min(0.85, flash)); if (scene) { if (scene.fog) { scene.fog.color.copy(skyCol); scene.fog.near = lerp(40, 8, storminess); scene.fog.far = lerp(160, 55, storminess); } } if (sun) sun.intensity = lerp(original.sun, original.sun * 0.12, storminess * darkness) + flash * 2.2; if (hemi) hemi.intensity = lerp(original.hemi, original.hemi * 0.3, storminess * darkness) + flash * 1.2; dome.position.copy(camPos); dome.material.opacity = storminess * 0.85; domeTex.offset.x = (domeTex.offset.x + scroll * dt * (0.4 + speed * 0.05)) % 1; domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1; // --- rain --- // Rebuild the shadow a few times a second, not every frame: the cloth // moves slowly next to the rain, and this is the only part that costs. shadowTick -= dt; if (shadowTick <= 0) { shadowTick = 0.1; rainVelocity(w, intensity, rainDir); const len = rainDir.length() || 1; shadow.update(world.sail, rainDir.x / len, rainDir.y / len, rainDir.z / len); } rain.step(dt, camPos, w, intensity, shadow); // --- audio --- audio.setLevels(speed, intensity); const tg = wind.gustTelegraph(t); if (tg && tg !== lastTelegraph) { // fires once per gust, right as the telegraph opens if (!lastTelegraph || Math.abs(tg.eta - (lastTelegraph.eta - dt)) > 0.05) { audio.whoosh(tg.power, tg.eta); } } lastTelegraph = tg; const sail = world.sail; if (sail && sail.corners) { let worst = 0, broken = false; for (const c of sail.corners) { if (c.broken) { broken = true; continue; } const rating = c.hw && c.hw.rating ? c.hw.rating : 1; worst = Math.max(worst, c.load / rating); } audio.creak(dt, worst); if (broken) audio.flog(dt, speed); } }, /** Hand Lane A's scene back exactly as we found it. */ dispose() { if (scene) { scene.remove(rain.mesh); scene.remove(dome); scene.background = original.background; if (ownsFog) { scene.fog = null; // we brought it; we take it } else if (original.fog) { scene.fog = original.fog; original.fog.color.copy(original.fogColor); original.fog.near = original.fogNear; original.fog.far = original.fogFar; } } if (sun) sun.intensity = original.sun; if (hemi) hemi.intensity = original.hemi; rain.dispose(); dome.geometry.dispose(); dome.material.dispose(); domeTex.dispose(); audio.dispose(); }, }; return fx; }