diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index 0877b40..f2f91e4 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -22,6 +22,112 @@ 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; @@ -55,22 +161,29 @@ function createRain(opts) { 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 */ - step(dt, camPos, w, intensity) { + /** + * @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; - const fall = 9 + intensity * 4; // rain leans into the wind; that lean IS the readout of how hard it's blowing - vel.set(w.x * 0.55, -fall, w.z * 0.55); + rainVelocity(w, intensity, vel); + const fall = -vel.y; const speed = vel.length() || 1; - q.setFromUnitVectors(up, vel.clone().divideScalar(speed)); + 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); @@ -91,6 +204,15 @@ function createRain(opts) { 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]; @@ -322,6 +444,9 @@ export function createSkyFx(o = {}) { 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); @@ -369,9 +494,22 @@ export function createSkyFx(o = {}) { const w = new THREE.Vector3(); const fx = { - rain, audio, dome, + 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(); }, @@ -431,7 +569,16 @@ export function createSkyFx(o = {}) { domeTex.offset.y = (domeTex.offset.y + scroll * dt * 0.12) % 1; // --- rain --- - rain.step(dt, camPos, w, intensity); + // 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); diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index 5348546..e82d024 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -18,7 +18,7 @@ import { assert, fixedLoop } from '../testkit.js'; import { FIXED_DT, checkContract, DEBRIS_PIECE_FIELDS } from '../contracts.js'; import { loadStorm, createWind } from '../weather.js'; import { createDebris } from '../debris.js'; -import { createSkyFx } from '../skyfx.js'; +import { createSkyFx, RainShadow } from '../skyfx.js'; import { weatherCases } from './weather.selftest.js'; const STORMS = ['storm_01_gentle', 'storm_02_wildnight']; @@ -154,6 +154,62 @@ export default async function run(t) { `skyfx left ${scene.children.length - before.children} object(s) in the scene`); }); + // --- SPRINT2 §Lane C.3: rain has to stop at the cloth --- + // Driven with a synthetic 4×4 m panel rather than a whole cloth sim: the thing + // under test is the projection, and a flat panel makes the right answer + // something you can work out on paper. + const PANEL = { + pos: new Float32Array([-2, 3, -2, 2, 3, -2, 2, 3, 2, -2, 3, 2]), + tris: [0, 1, 2, 0, 2, 3], + }; + + t.test('rain shadow: straight-down rain leaves a dry patch under the panel', () => { + const s = new RainShadow(); + s.update(PANEL, 0, -1, 0); + assert(s.live, 'shadow never built'); + assert(s.occluded(0, 1, 0), 'drop directly under the panel is still falling'); + assert(s.occluded(1.5, 0.1, 1.5), 'drop near the panel corner is still falling'); + assert(!s.occluded(0, 5, 0), 'drop ABOVE the panel was culled — it has not hit yet'); + assert(!s.occluded(8, 1, 0), 'drop well clear of the panel was culled'); + assert(!s.occluded(0, 1, 9), 'drop well clear of the panel was culled'); + }); + + t.test('rain shadow leans with the rain, and follows the wind round', () => { + const s = new RainShadow(); + // rain driving hard along +x: the dry ground moves +x, out from under the panel + s.update(PANEL, 0.6, -0.8, 0); + const shift = 3 * (0.6 / 0.8); // 3 m of fall × the lean + assert(s.occluded(shift, 0.05, 0), `dry patch is not downwind at x=${shift.toFixed(2)}`); + assert(!s.occluded(-shift, 0.05, 0), 'dry patch went UPWIND — the projection is inverted'); + + // swing the wind 180° and the patch has to swap sides. This is the southerly + // change: the sail stops covering the bed without a single corner failing. + s.update(PANEL, -0.6, -0.8, 0); + assert(s.occluded(-shift, 0.05, 0), 'dry patch did not follow the wind round'); + assert(!s.occluded(shift, 0.05, 0), 'dry patch stayed put when the wind swung'); + }); + + t.test('rain shadow: no sail, no shelter', () => { + const s = new RainShadow(); + s.update(null, 0, -1, 0); + assert(!s.live && !s.occluded(0, 1, 0), 'sheltered by a sail that does not exist'); + // and rain that is not falling can't cast a shadow (guards a divide by ~0) + s.update(PANEL, 1, 0, 0); + assert(!s.live, 'horizontal rain projected to infinity instead of bailing out'); + }); + + t.test('rain shadow: fractionOver reads a rect the way coverageOver does', () => { + const s = new RainShadow(); + s.update(PANEL, 0, -1, 0); + // the panel spans x,z in [-2,2]; a rect inside it is fully covered + assert(s.fractionOver({ x: 0, z: 0, w: 2, d: 2 }) === 1, + 'a rect wholly under the panel is not fully covered'); + assert(s.fractionOver({ x: 12, z: 0, w: 2, d: 2 }) === 0, + 'a rect nowhere near the panel is covered'); + const half = s.fractionOver({ x: 2, z: 0, w: 4, d: 2 }); + assert(half > 0.2 && half < 0.8, `a rect straddling the edge reads ${half}, want a partial`); + }); + t.test('every storm in data/storms/ loads and validates', () => { // loadStorm throws on invalid, so reaching here with all of them is the pass assert(Object.keys(storms).length === STORMS.length, 'a storm failed to load');