From 83375f2bae95745b8fd2030851d03b01d76029e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:10:12 +0000 Subject: [PATCH] MONKEY (rainshelter): camera-centred rain-occlusion height map 128x128 cells of 0.75 yd around the camera, recentred in 16-cell steps. Filled by vertical rays against the collision world (the precip ground oracle's SpatialQuery + body filter): top surface, then down each hit until an air gap >= 1.2 yd marks the column covered. Cover is eroded two cells (drip margin) and 3x3 blurred (soft edge), packed with the top height into one u32 per cell. 192 casts/frame while filling (nearest first), 32/frame refresh once full; idle and header off when dry or rainSurfaces=0. The grid rides the shared light buffer as a new region right after the MonkeyFrame block; the prop-probe region (and everything chained from it) moves up by 65568 B. Uploads only the header and changed rows. Verified: cargo test -p benilla-world --lib (652 pass, 9 new shelter tests: column probe, transform, recentre, budget, porch shelter/drip, packing, region alignment); cargo check -p benilla. Linux only; no platform seams. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R8t89gSUuo1uvbXKdh1mJt --- .../src/lighting/global_light.rs | 7 +- crates/benilla-world/src/lighting/mod.rs | 1 + .../benilla-world/src/lighting/prop_probes.rs | 5 +- crates/benilla-world/src/weather/mod.rs | 2 + crates/benilla-world/src/weather/shelter.rs | 740 ++++++++++++++++++ crates/benilla-world/src/weather/wetness.rs | 2 +- 6 files changed, 752 insertions(+), 5 deletions(-) create mode 100644 crates/benilla-world/src/weather/shelter.rs diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs index fd86a368..75351625 100644 --- a/crates/benilla-world/src/lighting/global_light.rs +++ b/crates/benilla-world/src/lighting/global_light.rs @@ -704,12 +704,15 @@ pub fn new_shared_light_buffer(device: &RenderDevice) -> SharedLightBuffer { /// whole layout at each draw. pub fn light_blob_bytes() -> u64 { per_frame_blob_bytes() + // MONKEY (rainshelter): the rain-occlusion grid region (`weather::shelter`). + + crate::weather::shelter::REGION_BYTES + (7 * MAX_PROP_PROBES * 16) as u64 + crate::rig_palette::palette_regions_bytes() } -/// The per-frame prefix's size, which is also the probe region's offset. -pub(super) fn per_frame_blob_bytes() -> u64 { +/// The per-frame prefix's size, which is also the rain-shelter region's offset (MONKEY +/// (rainshelter); the probe region follows that). +pub(crate) fn per_frame_blob_bytes() -> u64 { std::mem::size_of::() as u64 } diff --git a/crates/benilla-world/src/lighting/mod.rs b/crates/benilla-world/src/lighting/mod.rs index 74d72add..bcfa1597 100644 --- a/crates/benilla-world/src/lighting/mod.rs +++ b/crates/benilla-world/src/lighting/mod.rs @@ -66,6 +66,7 @@ pub use global_light::{ pub use prop_probes::{PropProbeSlot, PropProbes, MAX_PROP_PROBES}; // The std430 layout stays in the crate: off-world producers state values through `LightBlob`, // never a row index. +pub(crate) use global_light::per_frame_blob_bytes; // MONKEY (rainshelter) pub(crate) use prop_probes::prop_probe_region_offset; pub use resolve::WmoCrossfade; use resolve::{apply_sky_backdrop, setup_lighting, update_time_lighting}; diff --git a/crates/benilla-world/src/lighting/prop_probes.rs b/crates/benilla-world/src/lighting/prop_probes.rs index c6ddd114..01043ece 100644 --- a/crates/benilla-world/src/lighting/prop_probes.rs +++ b/crates/benilla-world/src/lighting/prop_probes.rs @@ -195,9 +195,10 @@ pub(super) fn publish_prop_probes( } } -/// Byte offset of the probe region in the shared light buffer, right after the per-frame blob. +/// Byte offset of the probe region in the shared light buffer, after the per-frame blob and +/// MONKEY (rainshelter) the rain-shelter grid region. pub fn prop_probe_region_offset() -> u64 { - super::global_light::per_frame_blob_bytes() + super::global_light::per_frame_blob_bytes() + crate::weather::shelter::REGION_BYTES } /// Render world, in `PrepareResources`: writes the probe region when the table changed; the diff --git a/crates/benilla-world/src/weather/mod.rs b/crates/benilla-world/src/weather/mod.rs index 09548546..10bc0526 100644 --- a/crates/benilla-world/src/weather/mod.rs +++ b/crates/benilla-world/src/weather/mod.rs @@ -16,6 +16,7 @@ use bevy::prelude::*; use crate::dev_state::DebugState; mod precip; +pub mod shelter; // MONKEY (rainshelter): the rain-occlusion height map the wet lane reads mod wetness; // MONKEY (wet): surface wetness + the rain-ripple clock -> MonkeyFrame wet0 pub use wetness::{RainSurfaces, Wetness}; @@ -312,6 +313,7 @@ impl Plugin for WeatherPlugin { .add_systems(Update, weather_tick.in_set(WeatherTick)); precip::register(app); wetness::register(app); // MONKEY (wet) + shelter::register(app); // MONKEY (rainshelter) } } diff --git a/crates/benilla-world/src/weather/shelter.rs b/crates/benilla-world/src/weather/shelter.rs new file mode 100644 index 00000000..adef8eb7 --- /dev/null +++ b/crates/benilla-world/src/weather/shelter.rs @@ -0,0 +1,740 @@ +//! MONKEY (rainshelter): the rain-occlusion height map. A camera-centred top-down grid +//! ([`GRID`]² cells of [`CELL`] yd, ~96 yd across, recentred in [`STEP`]-cell steps) that says, +//! per cell, how high the topmost surface is and whether open air lies under it, so the wet lane +//! (`wet_hook.wgsl`) keeps ground and walls under porches, bridges and roofs dry. +//! +//! Source: CPU vertical rays against the collision world, the same `SpatialQuery` and +//! [`WorldCollision::body_filter`](crate::collision::WorldCollision::body_filter) the precip +//! ground oracle lands drops on roofs with (`precip/pool.rs`, `HeightCache`). It is the cheapest +//! robust source: the colliders already exist (terrain, WMO groups, collidable doodads), cost +//! nothing to build, and see exactly the roofs the rain itself sees. Each cell casts down from +//! [`LIFT`] yd above the camera: the first hit is the top surface; then it keeps casting from just +//! under each hit (at most [`MAX_HOPS`]) until it finds an air gap of at least [`GAP`] yd (a roof, +//! a deck, a floor over a room) or runs out of surfaces (open ground). A roof's own shell (its +//! outer and ceiling faces a hand apart) is thinner than the gap, so it does not count as shelter. +//! Doodads without a canopy collider (most trees) do not shelter; that is a limit of the source. +//! +//! Per cell the GPU gets one `u32` ([`pack_cell`]): the low half the top height relative to the +//! header's `base_y` in 1/64 yd (±512 yd), the high half the soft cover 0..1 as unorm16. The cover is the +//! raw 0/1 gap flag eroded by two cells (the drip margin: ground just inside an eave stays wet) +//! then box-blurred over 3×3 (the soft edge), so it ramps 1/3, 2/3, 1 inward over ~2 yd. +//! A fragment is sheltered by `cover × smoothstep(0.5, 1.5, top − y)`: the roof itself (at `top`) +//! stays wet, everything a yard or more under it is dry. +//! +//! The grid rides the shared light buffer as its own region, right after the MonkeyFrame block +//! ([`region_offset`]), so no receiver needs a new binding. The region is two header rows +//! (`[origin_x, origin_z, 1/CELL, active]`, `[base_y, GRID, 0, 0]`) and `GRID²` words. +//! +//! Inert unless it is wet: with `rainSurfaces 0` or a zero wetness (`MonkeyFrame::wetness`) the +//! system casts nothing, the header's `active` is 0 and every reader skips the map. It stays live +//! while the ground dries after the rain, so a porch does not turn wet when the rain stops. +//! The precip splashes do not read the map: they already land on the topmost surface through the +//! reference's own ground oracle (`0x67c760`), which is the right answer for a drop. + +use std::sync::Arc; + +use avian3d::prelude::{SpatialQuery, SpatialQueryFilter}; +use bevy::prelude::*; +use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin}; +use bevy::render::renderer::RenderQueue; +use bevy::render::{Render, RenderApp, RenderSystems}; + +use crate::lighting::{MonkeyFrame, SharedLightBuffer}; +use crate::view::WorldCamera; + +/// Cells per side. +pub const GRID: usize = 128; +/// Cell size in yd (the grid spans `GRID × CELL` = 96 yd). +pub const CELL: f32 = 0.75; +/// The recentre step in cells (12 yd): the camera stays at least 48 cells from every edge. +pub const STEP: i32 = 16; +/// Ray start above the camera, yd. +pub const LIFT: f32 = 50.0; +/// Camera height drift (yd) that restarts the grid, since the ray start decides which roofs count. +const RECAST_DRIFT: f32 = 20.0; +/// How far a column is searched below its ray start, yd. +const REACH: f32 = 250.0; +/// The smallest air gap under a surface that counts as shelter, yd. +pub const GAP: f32 = 1.2; +/// Surfaces followed down a column before it is called open. +pub const MAX_HOPS: usize = 4; +/// Cells cast per frame while cells are unsampled (nearest the camera first). +pub const FILL_BUDGET: usize = 192; +/// Cells re-cast per frame once full, so streamed-in colliders show up within a few seconds. +pub const REFRESH_BUDGET: usize = 32; +/// The drip margin: covered cells this many cells from open sky count as open (1.5 yd), so a +/// column sampled just under an eave never leaks shelter outside it. +const ERODE: i32 = 2; +/// Header rows before the cells. +pub const HEADER_ROWS: usize = 2; +/// The region's size in the shared light buffer: the header and one word a cell. +pub const REGION_BYTES: u64 = (HEADER_ROWS * 16 + GRID * GRID * 4) as u64; +/// A missing top: far below anything, so `top − y` never shelters. +const NO_TOP: f32 = -60_000.0; + +/// Byte offset of the region: right after the per-frame blob (and before the prop probes). +pub fn region_offset() -> u64 { + crate::lighting::per_frame_blob_bytes() +} + +/// One column's answer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Column { + /// The topmost surface, world Y; `None` when the column hits nothing. + pub top: Option, + /// Open air of at least [`GAP`] under some surface of the column. + pub covered: bool, +} + +/// Walks one column down from `from_y` with `cast(origin_y, max_dist, first) -> hit distance`. +pub fn probe_column(from_y: f32, mut cast: impl FnMut(f32, f32, bool) -> Option) -> Column { + let Some(d) = cast(from_y, REACH, true) else { + return Column { + top: None, + covered: false, + }; + }; + let top = from_y - d; + let mut y = top; + for _ in 0..MAX_HOPS { + let start = y - 0.02; + let reach = start - (from_y - REACH); + if reach <= 0.0 { + break; + } + let Some(d) = cast(start, reach, false) else { + break; + }; + let next = start - d; + if y - next >= GAP { + return Column { + top: Some(top), + covered: true, + }; + } + y = next; + } + Column { + top: Some(top), + covered: false, + } +} + +/// Height steps a yard in a packed cell (1/64 yd over ±512 yd around `base_y`). +pub const HEIGHT_SCALE: f32 = 64.0; + +/// Packs a cell: the low half the top relative to `base_y` as `rel × 64 + 32768` (clamped), the +/// high half the cover as unorm16. Plain integers, so the shaders need no float16 capability. +pub fn pack_cell(top_rel: f32, cover: f32) -> u32 { + let h = (top_rel * HEIGHT_SCALE + 32_768.0) + .round() + .clamp(0.0, 65_535.0) as u32; + let c = (cover.clamp(0.0, 1.0) * 65_535.0).round() as u32; + h | (c << 16) +} + +/// The inverse of [`pack_cell`]: `(top_rel, cover)`, as `wet_hook::shelter_cell` decodes it. +pub fn unpack_cell(w: u32) -> (f32, f32) { + ( + ((w & 0xffff) as f32 - 32_768.0) / HEIGHT_SCALE, + (w >> 16) as f32 / 65_535.0, + ) +} + +/// The nearest-first cast order: cell indices by distance from the grid centre. +fn fill_order() -> Vec { + let c = GRID as f32 / 2.0 - 0.5; + let mut order: Vec = (0..GRID * GRID).map(|i| i as u16).collect(); + order.sort_by(|&a, &b| { + let d = |i: u16| { + let (x, z) = (f32::from(i) % GRID as f32, (usize::from(i) / GRID) as f32); + (x - c).powi(2) + (z - c).powi(2) + }; + d(a).total_cmp(&d(b)) + }); + order +} + +/// The grid: raw columns, the derived cover and the packed words the GPU reads. +#[derive(Resource)] +pub struct ShelterGrid { + /// World cell coordinates of cell (0, 0) (`floor(world / CELL)`). + pub origin: IVec2, + /// World Y the rays start from. + pub cast_y: f32, + top: Vec, + raw: Vec, + eroded: Vec, + sampled: Vec, + pending: usize, + refresh: usize, + order: Vec, + /// The packed words, shared with the render world. + pub cells: Arc>, + /// Grid rows changed since the last publish, `[lo, hi)`. + dirty: Option<(usize, usize)>, + /// Whether the map is live (it was wet this frame). + pub active: bool, + started: bool, +} + +impl Default for ShelterGrid { + fn default() -> Self { + let n = GRID * GRID; + Self { + origin: IVec2::ZERO, + cast_y: 0.0, + top: vec![NO_TOP; n], + raw: vec![0.0; n], + eroded: vec![0.0; n], + sampled: vec![false; n], + pending: n, + refresh: 0, + order: fill_order(), + cells: Arc::new(vec![pack_cell(NO_TOP, 0.0); n]), + dirty: None, + active: false, + started: false, + } + } +} + +impl ShelterGrid { + /// The origin a camera at `cam_xz` wants: its step, less half the grid. + pub fn origin_for(cam_xz: Vec2) -> IVec2 { + let cell = (cam_xz / CELL).floor().as_ivec2(); + (cell.div_euclid(IVec2::splat(STEP)) - IVec2::splat(GRID as i32 / STEP / 2)) * STEP + } + + /// The Y relative heights are stored against. + pub fn base_y(&self) -> f32 { + self.cast_y - LIFT + } + + /// The cell holding a world XZ, if inside. + pub fn cell_of(&self, xz: Vec2) -> Option<(usize, usize)> { + let c = (xz / CELL).floor().as_ivec2() - self.origin; + let n = GRID as i32; + (c.x >= 0 && c.y >= 0 && c.x < n && c.y < n).then_some((c.x as usize, c.y as usize)) + } + + /// A cell's centre in world XZ. + pub fn cell_centre(&self, x: usize, z: usize) -> Vec2 { + (self.origin.as_vec2() + Vec2::new(x as f32 + 0.5, z as f32 + 0.5)) * CELL + } + + /// Cells still to cast before the grid is whole. + pub fn pending(&self) -> usize { + self.pending + } + + /// Forgets every column. + pub fn clear(&mut self) { + self.top.fill(NO_TOP); + self.raw.fill(0.0); + self.sampled.fill(false); + self.pending = GRID * GRID; + self.refresh = 0; + self.rederive_all(); + } + + /// Follows the camera: shifts the grid by whole steps (keeping every column still inside) + /// and restarts it when the camera has climbed or dropped past [`RECAST_DRIFT`]. Returns + /// whether anything moved. + pub fn follow(&mut self, cam: Vec3) -> bool { + let want_y = cam.y + LIFT; + if !self.started || (want_y - self.cast_y).abs() > RECAST_DRIFT { + self.started = true; + self.cast_y = want_y; + self.origin = Self::origin_for(cam.xz()); + self.clear(); + return true; + } + let want = Self::origin_for(cam.xz()); + if want == self.origin { + return false; + } + let d = want - self.origin; + self.origin = want; + let n = GRID as i32; + let mut top = vec![NO_TOP; GRID * GRID]; + let mut raw = vec![0.0; GRID * GRID]; + let mut sampled = vec![false; GRID * GRID]; + for z in 0..n { + for x in 0..n { + let (ox, oz) = (x + d.x, z + d.y); + if ox < 0 || oz < 0 || ox >= n || oz >= n { + continue; + } + let (i, o) = ((z * n + x) as usize, (oz * n + ox) as usize); + top[i] = self.top[o]; + raw[i] = self.raw[o]; + sampled[i] = self.sampled[o]; + } + } + self.top = top; + self.raw = raw; + self.pending = sampled.iter().filter(|s| !**s).count(); + self.sampled = sampled; + self.rederive_all(); + true + } + + /// Casts up to a frame's budget of columns with `probe(xz)`, nearest unsampled first, then a + /// slow refresh sweep. Returns how many columns were cast. + pub fn step(&mut self, mut probe: impl FnMut(Vec2) -> Column) -> usize { + let mut cast = 0; + if self.pending > 0 { + for k in 0..self.order.len() { + if cast == FILL_BUDGET { + break; + } + let i = usize::from(self.order[k]); + if self.sampled[i] { + continue; + } + self.cast_cell(i, &mut probe); + cast += 1; + } + } else { + for _ in 0..REFRESH_BUDGET { + let i = usize::from(self.order[self.refresh]); + self.refresh = (self.refresh + 1) % self.order.len(); + self.cast_cell(i, &mut probe); + cast += 1; + } + } + cast + } + + fn cast_cell(&mut self, i: usize, probe: &mut impl FnMut(Vec2) -> Column) { + let (x, z) = (i % GRID, i / GRID); + let col = probe(self.cell_centre(x, z)); + if !self.sampled[i] { + self.sampled[i] = true; + self.pending -= 1; + } + let top = col.top.unwrap_or(NO_TOP); + let raw = if col.covered { 1.0 } else { 0.0 }; + if top != self.top[i] || raw != self.raw[i] { + self.top[i] = top; + self.raw[i] = raw; + self.rederive(x, z); + } + } + + fn rederive_all(&mut self) { + self.rederive_rect(0, 0, GRID - 1, GRID - 1); + } + + /// A raw change reaches the cover `ERODE + 1` cells out (erode, then blur). + fn rederive(&mut self, x: usize, z: usize) { + let r = ERODE as usize + 1; + self.rederive_rect( + x.saturating_sub(r), + z.saturating_sub(r), + (x + r).min(GRID - 1), + (z + r).min(GRID - 1), + ); + } + + /// Recomputes the cover and the packed words over an inclusive rect. + fn rederive_rect(&mut self, x0: usize, z0: usize, x1: usize, z1: usize) { + let n = GRID as i32; + let at = |v: &Vec, x: i32, z: i32| { + if x < 0 || z < 0 || x >= n || z >= n { + 0.0 + } else { + v[(z * n + x) as usize] + } + }; + // Erosion over the rect grown by one (the blur reads it). + let (ex0, ez0) = (x0.saturating_sub(1), z0.saturating_sub(1)); + let (ex1, ez1) = ((x1 + 1).min(GRID - 1), (z1 + 1).min(GRID - 1)); + for z in ez0..=ez1 { + for x in ex0..=ex1 { + let mut m = 1.0f32; + for dz in -ERODE..=ERODE { + for dx in -ERODE..=ERODE { + m = m.min(at(&self.raw, x as i32 + dx, z as i32 + dz)); + } + } + self.eroded[z * GRID + x] = m; + } + } + let base = self.base_y(); + let cells = Arc::make_mut(&mut self.cells); + for z in z0..=z1 { + for x in x0..=x1 { + let mut s = 0.0; + for dz in -1..=1 { + for dx in -1..=1 { + s += at(&self.eroded, x as i32 + dx, z as i32 + dz); + } + } + let i = z * GRID + x; + cells[i] = pack_cell(self.top[i] - base, s / 9.0); + } + } + self.dirty = Some(match self.dirty { + Some((lo, hi)) => (lo.min(z0), hi.max(z1 + 1)), + None => (z0, z1 + 1), + }); + } + + /// The two header rows. + pub fn header(&self) -> [[f32; 4]; HEADER_ROWS] { + let o = self.origin.as_vec2() * CELL; + [ + [o.x, o.y, 1.0 / CELL, if self.active { 1.0 } else { 0.0 }], + [self.base_y(), GRID as f32, 0.0, 0.0], + ] + } + + /// The sheltered amount a fragment at `p` gets: the CPU twin of `wet_hook::shelter_amount` + /// (bilinear over the four nearest cell centres), for tests and probes. + pub fn shelter_at(&self, p: Vec3) -> f32 { + if !self.active { + return 0.0; + } + let g = (p.xz() / CELL - self.origin.as_vec2()) - 0.5; + let gf = g.floor(); + if gf.x < 0.0 + || gf.y < 0.0 + || gf.x + 1.0 > (GRID - 1) as f32 + || gf.y + 1.0 > (GRID - 1) as f32 + { + return 0.0; + } + let f = g - gf; + let (x, z) = (gf.x as usize, gf.y as usize); + let y_rel = p.y - self.base_y(); + let tap = |x: usize, z: usize| { + let (top, cover) = unpack_cell(self.cells[z * GRID + x]); + let t = (top - y_rel - 0.5).clamp(0.0, 1.0); + cover * t * t * (3.0 - 2.0 * t) + }; + let a = tap(x, z) + (tap(x + 1, z) - tap(x, z)) * f.x; + let b = tap(x, z + 1) + (tap(x + 1, z + 1) - tap(x, z + 1)) * f.x; + (a + (b - a) * f.y).clamp(0.0, 1.0) + } +} + +/// The render-world copy: the header every frame, the cells when they changed. +#[derive(Resource, Clone, ExtractResource)] +pub struct ShelterExtract { + header: [[f32; 4]; HEADER_ROWS], + cells: Arc>, + generation: u64, + /// Rows this generation changed, `[lo, hi)`. + rows: Option<(usize, usize)>, +} + +impl Default for ShelterExtract { + fn default() -> Self { + Self { + header: [[0.0; 4]; HEADER_ROWS], + cells: Arc::new(Vec::new()), + generation: 0, + rows: None, + } + } +} + +/// Main world: follows the camera and casts this frame's columns while it is wet; idle (no rays, +/// the header marked off once) otherwise. +fn tick_shelter( + frame: Res, + cam: Query<&GlobalTransform, With>, + spatial: SpatialQuery, + mut grid: ResMut, + mut out: ResMut, +) { + let wet = frame.wetness > 0.0; + let cam = cam.single().ok().map(GlobalTransform::translation); + let live = wet && cam.is_some(); + if !live { + if grid.active { + grid.active = false; + // Stale columns would lie on the next shower; forget them. + grid.started = false; + out.header = grid.header(); + } + return; + } + let cam = cam.unwrap_or_default(); + grid.active = true; + grid.follow(cam); + let filter = crate::collision::WorldCollision::body_filter(); + let x0 = grid.cast_y; + grid.step(|xz| cast_column(&spatial, &filter, xz, x0)); + let header = grid.header(); + if out.header != header { + out.header = header; + } + if let Some(rows) = grid.dirty.take() { + out.cells = Arc::clone(&grid.cells); + out.generation = out.generation.wrapping_add(1); + out.rows = Some(rows); + } +} + +fn cast_column( + spatial: &SpatialQuery, + filter: &SpatialQueryFilter, + xz: Vec2, + from_y: f32, +) -> Column { + probe_column(from_y, |y, max, first| { + spatial + .cast_ray(Vec3::new(xz.x, y, xz.y), Dir3::NEG_Y, max, first, filter) + .map(|hit| hit.distance) + }) +} + +/// Render world: writes the header each frame it moved and the changed cell rows. +fn upload_shelter( + queue: Res, + buffer: Option>, + data: Option>, + mut last: Local<(u64, [[f32; 4]; HEADER_ROWS])>, +) { + let (Some(buffer), Some(data)) = (buffer, data) else { + return; + }; + if last.1 != data.header { + last.1 = data.header; + queue.write_buffer( + &buffer.0, + region_offset(), + bytemuck::cast_slice(&data.header), + ); + } + if last.0 == data.generation || data.cells.is_empty() { + return; + } + // Only the changed rows after a consecutive generation; everything after a skipped one. + let (lo, hi) = match data.rows { + Some(r) if last.0.wrapping_add(1) == data.generation => r, + _ => (0, GRID), + }; + last.0 = data.generation; + let words = &data.cells[lo * GRID..hi * GRID]; + queue.write_buffer( + &buffer.0, + region_offset() + (HEADER_ROWS * 16 + lo * GRID * 4) as u64, + bytemuck::cast_slice(words), + ); +} + +pub(super) fn register(app: &mut App) { + app.init_resource::() + .init_resource::() + .add_plugins(ExtractResourcePlugin::::default()) + .add_systems(Update, tick_shelter.after(super::wetness::wetness_tick)); + if let Some(render_app) = app.get_sub_app_mut(RenderApp) { + render_app.add_systems( + Render, + upload_shelter.in_set(RenderSystems::PrepareResources), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A flat ground at y 0 with a 6×6 yd porch roof (outer face 3.2, ceiling 3.0) at x, z 10..16. + fn porch(xz: Vec2) -> impl FnMut(f32, f32, bool) -> Option { + move |y: f32, max: f32, _first: bool| { + let under = (10.0..16.0).contains(&xz.x) && (10.0..16.0).contains(&xz.y); + let surfaces: &[f32] = if under { &[3.2, 3.0, 0.0] } else { &[0.0] }; + surfaces + .iter() + .find(|&&s| s <= y && y - s <= max) + .map(|&s| y - s) + } + } + + fn filled(cam: Vec3) -> ShelterGrid { + let mut g = ShelterGrid { + active: true, + ..Default::default() + }; + g.follow(cam); + let from = g.cast_y; + while g.pending() > 0 { + g.step(|xz| probe_column(from, porch(xz))); + } + g + } + + #[test] + fn a_column_under_a_roof_is_covered_and_open_ground_is_not() { + let roof = probe_column(50.0, porch(Vec2::new(12.0, 12.0))); + assert!( + roof.covered && (roof.top.unwrap() - 3.2).abs() < 1e-4, + "{roof:?}" + ); + let open = probe_column(50.0, porch(Vec2::new(0.0, 0.0))); + assert_eq!( + open, + Column { + top: Some(0.0), + covered: false + } + ); + let none = probe_column(50.0, |_, _, _| None); + assert_eq!( + none, + Column { + top: None, + covered: false + } + ); + } + + #[test] + fn a_thin_shell_is_not_shelter() { + // Two faces 0.3 yd apart and nothing under them: a slab lying on the ground. + let c = probe_column(10.0, |y, _, _| { + [1.3f32, 1.0].iter().find(|&&s| s <= y).map(|&s| y - s) + }); + assert!(!c.covered); + } + + #[test] + fn the_transform_round_trips_and_the_camera_sits_mid_grid() { + for cam in [ + Vec2::new(0.0, 0.0), + Vec2::new(-8_532.4, 671.9), + Vec2::new(123.4, -9_876.5), + ] { + let mut g = ShelterGrid::default(); + g.follow(cam.extend(20.0).xzy()); + let (x, z) = g.cell_of(cam).expect("the camera is inside its grid"); + for c in [x, z] { + assert!((48..80).contains(&c), "camera cell {c} too near an edge"); + } + let centre = g.cell_centre(x, z); + assert!((centre - cam).abs().max_element() <= CELL / 2.0 + 1e-3); + assert_eq!(g.cell_of(centre), Some((x, z))); + } + } + + #[test] + fn the_grid_recentres_only_in_whole_steps_and_keeps_its_columns() { + let mut g = filled(Vec3::new(13.0, 1.0, 13.0)); + let origin = g.origin; + // Within a step: nothing moves. + assert!(!g.follow(Vec3::new(13.0 + 5.0, 1.0, 13.0))); + assert_eq!(g.origin, origin); + // Past it: one step, and only the new strip is pending. + assert!(g.follow(Vec3::new(13.0 + STEP as f32 * CELL, 1.0, 13.0))); + assert_eq!(g.origin - origin, IVec2::new(STEP, 0)); + assert_eq!(g.pending(), STEP as usize * GRID); + // The porch kept its shelter across the shift. + assert!(g.shelter_at(Vec3::new(13.0, 0.0, 13.0)) > 0.99); + // A big climb restarts the grid. + assert!(g.follow(Vec3::new( + 13.0 + STEP as f32 * CELL, + 1.0 + RECAST_DRIFT + 1.0, + 13.0 + ))); + assert_eq!(g.pending(), GRID * GRID); + } + + #[test] + fn a_frame_casts_at_most_its_budget_nearest_first() { + let mut g = ShelterGrid { + active: true, + ..Default::default() + }; + g.follow(Vec3::new(13.0, 1.0, 13.0)); + let mut calls = 0; + let mut far = 0.0f32; + let n = g.step(|xz| { + calls += 1; + far = far.max((xz - Vec2::new(13.0, 13.0)).length()); + Column { + top: Some(0.0), + covered: false, + } + }); + assert_eq!((n, calls), (FILL_BUDGET, FILL_BUDGET)); + // The first frame covers the camera's own neighbourhood (grid centre is ≤ a step away). + assert!(far < 20.0, "first cells reach {far} yd"); + assert_eq!(g.pending(), GRID * GRID - FILL_BUDGET); + while g.pending() > 0 { + g.step(|_| Column { + top: Some(0.0), + covered: false, + }); + } + // Full: only the refresh trickle. + assert_eq!( + g.step(|_| Column { + top: Some(0.0), + covered: false + }), + REFRESH_BUDGET + ); + } + + #[test] + fn under_the_porch_is_dry_the_roof_and_the_drip_line_are_wet() { + let g = filled(Vec3::new(13.0, 1.0, 13.0)); + // Ground under the middle of the porch: sheltered. + assert!(g.shelter_at(Vec3::new(13.0, 0.0, 13.0)) > 0.99); + // The roof's own top: open. + assert_eq!(g.shelter_at(Vec3::new(13.0, 3.2, 13.0)), 0.0); + // Open ground: open. + assert_eq!(g.shelter_at(Vec3::new(5.0, 0.0, 5.0)), 0.0); + // Just inside the eave (the drip margin): mostly wet; a yard in: mostly dry. + let edge = g.shelter_at(Vec3::new(10.2, 0.0, 13.0)); + let inner = g.shelter_at(Vec3::new(11.8, 0.0, 13.0)); + assert!(edge < 0.5, "drip line {edge}"); + assert!(inner > edge && inner > 0.6, "inner {inner}"); + // At and just outside the eave: fully wet. + assert_eq!(g.shelter_at(Vec3::new(9.6, 0.0, 13.0)), 0.0); + assert_eq!(g.shelter_at(Vec3::new(10.1, 0.0, 13.0)), 0.0); + // Walls and floors a story under the roof are dry too; a fragment above it is not. + assert!(g.shelter_at(Vec3::new(13.0, 1.5, 13.0)) > 0.99); + assert_eq!(g.shelter_at(Vec3::new(13.0, 4.0, 13.0)), 0.0); + } + + #[test] + fn an_inactive_grid_shelters_nothing_and_packs_off() { + let mut g = filled(Vec3::new(13.0, 1.0, 13.0)); + g.active = false; + assert_eq!(g.shelter_at(Vec3::new(13.0, 0.0, 13.0)), 0.0); + assert_eq!(g.header()[0][3], 0.0); + } + + #[test] + fn packed_cells_round_trip_to_a_sixty_fourth_of_a_yard() { + for (top, cover) in [ + (0.0f32, 0.0f32), + (1.0, 1.0), + (-2.5, 0.5), + (3.21, 1.0 / 3.0), + (-60.0, 0.0), + ] { + let (t, c) = unpack_cell(pack_cell(top, cover)); + assert!((t - top).abs() <= 0.5 / HEIGHT_SCALE, "{top} -> {t}"); + assert!((c - cover).abs() <= 1e-4, "{cover} -> {c}"); + } + // Out of range clamps: a missing top reads 512 yd under the base. + assert_eq!(unpack_cell(pack_cell(NO_TOP, 2.0)), (-512.0, 1.0)); + } + + #[test] + fn the_region_is_aligned_for_the_probe_rows_after_it() { + assert_eq!(REGION_BYTES % 16, 0); + assert_eq!(region_offset() % 16, 0); + assert_eq!( + crate::lighting::prop_probe_region_offset(), + region_offset() + REGION_BYTES + ); + } +} diff --git a/crates/benilla-world/src/weather/wetness.rs b/crates/benilla-world/src/weather/wetness.rs index ebcf73df..87512e27 100644 --- a/crates/benilla-world/src/weather/wetness.rs +++ b/crates/benilla-world/src/weather/wetness.rs @@ -85,7 +85,7 @@ fn env_overrides() -> &'static EnvOverrides { }) } -fn wetness_tick( +pub(super) fn wetness_tick( time: Res>, state: Res, cvar: Res,