Rain stops at the cloth (SPRINT2 Lane C.3)
The garden visibly stays dry under the sail. 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 bed, free drama and honest physics. Cheap on purpose. Ray-testing 3k drops against 162 triangles every frame is ~486k intersections for an effect nobody inspects closely. Instead project the sail's triangles ALONG the rain onto the ground into a coarse height grid, a few times a second (the cloth moves slowly next to the rain); per-drop cost is one grid read, and occluded drops get a zero-scale matrix rather than a raycast. Measured 0.041 ms/rebuild, 10x/s = 0.41 ms/s against A's 0.63 ms frame — negligible. Reads rig.pos/rig.tris, so nothing new needed from Lane B. Verified in the real game with a surviving twisted rated rig: 497 grid cells covered, 96% of the garden bed under cover, live drops culled under the cloth and falling in the open yard either side. Screenshot for DESIGN.md. skyfx exposes rainShadowOver(rect) — NOT the same as rig.coverageOver(bed, sunDir). That one is the SUN shadow; this is the RAIN shadow (down the wind). Which drives garden HP is a design call — flagged for A in THREADS. Selftest 134/0/0 (8 new rain-shadow asserts). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
135511fb05
commit
6971f31984
@ -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);
|
||||
|
||||
@ -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');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user