diff --git a/LIGHTING.md b/LIGHTING.md index 3db1d434..70b76924 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -30,7 +30,7 @@ behind each constant. | HDR emission and bloom | lit WMO windows, additive models/particles and magma can exceed display white; a soft-capped fullscreen halo is added before the faithful FFX clamp | Advanced Graphics → Bloom (Off/Low/High), cvar `bloom`; Off is the unchanged reference image, suggested High preset value 2 | | Sky quality | Enhanced: the five Light.dbc sky stops through a smooth monotone curve in linear light (no bands at the rings), a soft sun glow tinted by sun and fog colour (fades at night and under cloud), a procedural star field with twinkle and a faint Milky Way over the stock `Stars.m2`. High adds domain-warped cloud detail and sun-lit clouds (self-shadow, silver lining; technique from WarcraftXL, see `THIRD-PARTY.md`). Classic is the reference sky unchanged | Advanced Graphics → Sky Quality, cvar `skyQuality` 0 Classic / 1 Enhanced / 2 High, env `WOW_SKY_QUALITY` | | Modern fog | MONKEY (fog): radial distance fog with a gradual exponential curve, a daylight sun lobe, and an end colour shared by the world, WDL hull, sky horizon and volumetric haze. Each zone keeps its authored fog end up to the reference view-distance limit; optional `LightFogBand.dbc` rows provide height, sun and end-fog controls. Interior WMO fog stays classic | Advanced Graphics → Modern Fog, cvar `fogModel` 0 Classic (default) / 1 Modern (High = 1), env `WOW_FOGMODEL=0\|1`; `fog_hook.wgsl`, `lighting/fog_model.rs`, `light/fog_band.rs` | -| Rain on surfaces | MONKEY (wet): rain gradually darkens and saturates exposed terrain and models, adds puddles and a sky/sun sheen, and produces rings on exterior Enhanced water. Wetness rises over roughly 90 seconds and dries over roughly four minutes; phase 1 has no rain-occlusion map, so shelter is classified only by interior/unlit state and surface facing | Advanced Graphics → Wet Surfaces in Rain, cvar `rainSurfaces` 0/1 (default and High 1), env `WOW_RAIN_SURFACES`, `WOW_WETNESS`, `WOW_WET_T`; `weather/wetness.rs`, `wet_hook.wgsl` | +| Rain on surfaces | MONKEY (wet): rain gradually darkens and saturates exposed terrain and models, adds puddles and a sky/sun sheen, and produces rings on exterior Enhanced water. Wetness rises over roughly 90 seconds and dries over roughly four minutes; MONKEY (rainshelter): a camera-centred rain-occlusion map (128² cells of 0.75 yd, filled by vertical collision rays) keeps ground, walls and water under roofs, porches and bridges dry, with a ~1.5 yd drip margin and a soft edge; tree canopies without colliders do not shelter | Advanced Graphics → Wet Surfaces in Rain, cvar `rainSurfaces` 0/1 (default and High 1), env `WOW_RAIN_SURFACES`, `WOW_WETNESS`, `WOW_WET_T`; `weather/wetness.rs`, `weather/shelter.rs`, `wet_hook.wgsl` | | Foliage wind | one weather-fed gust/veer field drives grass and classified tree/bush foliage; grass also parts around the player and nearby units | Advanced Graphics → Foliage Wind, cvar `foliageWind` 0 Off / 1 Grass / 2 Grass + Trees (High = 2), capture override `WOW_FOLIAGE_WIND`; field and benders in `benilla-world/src/wind/`. The wave phase is the CPU-integrated travel (speed integrated over time, wrapped seamlessly), and the waves' spatial term uses the fixed profile heading; the veer turns only the bend. Tree exile copies sway through material marker bit 14 (`FOLIAGE_WIND_MARKER`). Capture offset `WOW_CAPTURE_WIND_T` (s). Known: shadow casters (world-shadow proxies, `torch_depth.wgsl`) do not apply the sway, so leaves sample a static shadow; a future world-camera DepthPrepass must call the same offsets | | Ambient occlusion | soft contact shadows under props, in wall-floor corners and around feet; depth-only SSAO at half resolution (depth-reconstructed normals, 6/12 spiral taps, 4x4 depth-aware blur, bilateral upsample) multiplied into the opaque scene after the opaque pass and before the water copy, so water, particles and UI are untouched; fades out 45-90 yd (Low) / 60-120 yd (High), spares sky and bright pixels (`benilla-app/src/ssao.rs`, `benilla-app/src/shaders/ssao.wgsl`) | Advanced Graphics → Ambient Occlusion (Off/Low/High; default Off, High preset 2), cvar `ambientOcclusion`, env `WOW_AO=0\|1\|2`, `WOW_AO_DEBUG=1..4` diagnostic views (factor, protection, distance, raw occlusion), `WOW_AO_GAIN`/`_RADIUS`/`_BIAS`/`_STRENGTH` tuning | | Lamp fog | MONKEY (lampfog): the fog fullscreen pass selects the nearest resolved exterior and interior point-table sources and analytically integrates their softened inverse-square light over the camera-to-scene-depth ray. A near-field haze floor makes lamps visible inside the ordinary volumetric haze's 10 yd clear zone; it is zero by day, stronger in rain/fog, and deliberately unshadowed. The table colour already includes intensity, flame flicker and `fireLightGain`. Halo output uses destination headroom and stays below 1.0, so a later bloom pass cannot blow it out | Advanced Graphics → Lamp Fog, cvar `lampFog` 0 Off (default, unchanged) / 1 Low (16 lamps) / 2 High (32 lamps; Graphics preset High = 2), env `WOW_LAMPFOG=0\|1\|2`; `benilla-app/src/volumetric_fog.rs` | @@ -138,6 +138,11 @@ is its own module, documented in `WATER.md`. | `misc` | bender_count | time_of_day 0..1 | night 0..1 | 0 | | `benders[8]` | world x | world y | world z | radius | + MONKEY (rainshelter): the rain-shelter region follows the MonkeyFrame block (row 549: two + header rows, then 16384 packed `u32` cells, 65568 B; `weather/shelter.rs`), and the prop-probe + region moved up by that much. `terrain`, `wow_model` and `static_gx` mirror it as + `shelter_hdr`, `shelter_cfg`, `shelter`; `enhanced_water` reads it through its row view. + Positions are Bevy world space (Y up, yards; WoW `(x, y, z)` = Bevy `(-y, z, -x)`), the wind direction is a unit vector in world XZ. Lanes write the `MonkeyFrame` resource (`benilla-world/src/lighting/monkey_frame.rs`); `global_light::pack_monkey_frame` packs it after diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl index 411ffc0f..faf60bd9 100644 --- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl +++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl @@ -25,6 +25,8 @@ #import bevy_pbr::mesh_view_bindings::{view, globals} // MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table. #import benilla::monkey_frame +// MONKEY (rainshelter): the shelter-grid lookup the surface receivers share. +#import benilla::wet_hook // MONKEY (p0 fog hook): the one distance-fog law every receiver calls. #import benilla::fog_hook @@ -66,6 +68,25 @@ fn water_monkey() -> monkey_frame::MonkeyFrame { return m; } +// MONKEY (rainshelter): the rain-occlusion grid right after the MonkeyFrame block +// (`weather/shelter.rs`): two header rows, then the packed cell words, four to a row. +const SHELTER_ROW: u32 = 549u; +fn water_shelter_word(i: u32) -> u32 { + return bitcast(water_light[SHELTER_ROW + 2u + i / 4u][i % 4u]); +} +// 1 open .. 0 under a bridge or a roof. +fn water_rain_open(world_pos: vec3) -> f32 { + let hdr = water_light[SHELTER_ROW]; + let cfg = water_light[SHELTER_ROW + 1u]; + let st = wet_hook::shelter_taps(world_pos, hdr, cfg); + if (st.live <= 0.0) { + return 1.0; + } + return 1.0 - wet_hook::shelter_amount(st, water_shelter_word(st.idx.x), + water_shelter_word(st.idx.y), water_shelter_word(st.idx.z), water_shelter_word(st.idx.w), + world_pos.y, cfg); +} + // What the fragment stage hands over: the three varyings this module reads. struct WaterFragment { clip_position: vec4, @@ -256,13 +277,16 @@ fn ripple_hash(p: vec2) -> vec2 { q += dot(q, q.yzx + 33.33); return fract((q.xx + q.yz) * q.zy); } -fn rain_ripple_normal(world_xz: vec2, footprint: f32, distance: f32, room: bool) -> vec2 { +fn rain_ripple_normal(world_pos: vec3, footprint: f32, distance: f32, room: bool) -> vec2 { let wet_row = water_monkey().wet_a; let rain = clamp(wet_row.x, 0.0, 1.0); if rain <= 0.0 || water.lane.x > 1.5 || room { return vec2(0.0); } - let reach = (1.0 - smoothstep(35.0, 60.0, distance)) * (1.0 - smoothstep(0.05, 0.14, footprint)); + let world_xz = world_pos.xz; + // MONKEY (rainshelter): no rings under a bridge or a roof. + let reach = (1.0 - smoothstep(35.0, 60.0, distance)) * (1.0 - smoothstep(0.05, 0.14, footprint)) + * water_rain_open(world_pos); if reach <= 0.0 { return vec2(0.0); } @@ -595,7 +619,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec open_sea_fold = water_gerstner(p, t, shore).w; } // One surface gradient: the procedural bands and the shore break. - let surf_grad = wave.yz + shore_grad + rain_ripple_normal(in.world_position.xz, footprint, + let surf_grad = wave.yz + shore_grad + rain_ripple_normal(in.world_position.xyz, footprint, length(eye_pos), in.room_fog != 0u && water.lane.w > 0.5); var n = normalize(vec3(-surf_grad.x, 1.0, -surf_grad.y)); if dot(n, to_view) < 0.0 { n = -n; } diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl index 9bc38773..4ea363e3 100644 --- a/crates/benilla-assets/src/shaders/terrain.wgsl +++ b/crates/benilla-assets/src/shaders/terrain.wgsl @@ -64,6 +64,11 @@ struct WowLight { points: array, 512>, // MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl). monkey: monkey_frame::MonkeyFrame, + // MONKEY (rainshelter): the rain-occlusion grid (`weather/shelter.rs`): `[origin_x, origin_z, + // 1/cell, active]`, `[base_y, cells per side, 0, 0]`, then one packed word a cell (wet_hook.wgsl). + shelter_hdr: vec4, + shelter_cfg: vec4, + shelter: array, }; @group(#{MATERIAL_BIND_GROUP}) @binding(90) var wow_light: WowLight; @@ -757,9 +762,20 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 { // specular = per-vertex sheen · gloss_mask · shadow → gated to ZERO in shadow (no sheen in shade) // (`tex·primary` is the MODULATE-1× diffuse; the sheen is added after, separate-specular.) Then // LDR-clamp; gamma/byte throughout; raw gamma out (GAMMA LANE, 0161). + // MONKEY (rainshelter): nothing gets wet under a roof, porch or bridge (the shelter grid). + var wet_open = 1.0; + if (wow_light.monkey.wet_a.y > 0.0) { + let wet_st = wet_hook::shelter_taps(in.world_position.xyz, wow_light.shelter_hdr, + wow_light.shelter_cfg); + if (wet_st.live > 0.0) { + wet_open = 1.0 - wet_hook::shelter_amount(wet_st, wow_light.shelter[wet_st.idx.x], + wow_light.shelter[wet_st.idx.y], wow_light.shelter[wet_st.idx.z], + wow_light.shelter[wet_st.idx.w], in.world_position.y, wow_light.shelter_cfg); + } + } // MONKEY (wet): rain-darkened ground + puddles (wet_hook.wgsl); a dry frame returns `color`. - let wet = wet_hook::wet_puddles(wet_hook::wet_surface(color, n_lit, in.world_position.xyz, 1.0, - wow_light.monkey), n_lit, in.world_position.xyz, wow_light.monkey); + let wet = wet_hook::wet_puddles(wet_hook::wet_surface(color, n_lit, in.world_position.xyz, + wet_open, wow_light.monkey), n_lit, in.world_position.xyz, wet_open, wow_light.monkey); color = wet.albedo; let diffuse_term = color * primary * (0.3 * shadow_lit_eff + 0.7) * character_shadow_term; let spec_term = in.specular * specmask * spec_gate; diff --git a/crates/benilla-assets/src/shaders/wet_hook.wgsl b/crates/benilla-assets/src/shaders/wet_hook.wgsl index 4bf3b73b..c09903e4 100644 --- a/crates/benilla-assets/src/shaders/wet_hook.wgsl +++ b/crates/benilla-assets/src/shaders/wet_hook.wgsl @@ -13,6 +13,57 @@ #import benilla::monkey_frame::MonkeyFrame +// MONKEY (rainshelter): the four cells around a fragment in the camera-centred shelter grid. +// `hdr` = `[origin_x, origin_z, 1 / cell, active]`, `cfg` = `[base_y, cells per side, 0, 0]`; +// `live` is 0 when the map is off or the fragment lies outside it (open sky). +struct ShelterTaps { + idx: vec4, + frac: vec2, + live: f32, +} + +fn shelter_taps(world_pos: vec3, hdr: vec4, cfg: vec4) -> ShelterTaps { + var t: ShelterTaps; + t.idx = vec4(0u); + t.frac = vec2(0.0); + t.live = 0.0; + if (hdr.w < 0.5) { + return t; + } + let g = (world_pos.xz - hdr.xy) * hdr.z - 0.5; + let gf = floor(g); + if (gf.x < 0.0 || gf.y < 0.0 || gf.x + 1.0 > cfg.y - 1.0 || gf.y + 1.0 > cfg.y - 1.0) { + return t; + } + let side = u32(cfg.y); + let first = u32(gf.y) * side + u32(gf.x); + t.idx = vec4(first, first + 1u, first + side, first + side + 1u); + t.frac = g - gf; + t.live = 1.0; + return t; +} + +// One cell: low half `(top − base_y) × 64 + 32768`, high half the soft cover as unorm16 +// (`shelter::pack_cell`). Sheltered when a covered top lies a yard or more above the fragment; the +// roof's own surface (at `top`) stays open. +fn shelter_cell(word: u32, y_rel: f32) -> f32 { + let top = (f32(word & 0xffffu) - 32768.0) / 64.0; + let cover = f32(word >> 16u) / 65535.0; + return cover * smoothstep(0.5, 1.5, top - y_rel); +} + +// How sheltered the fragment is, 0 open .. 1 under cover, bilinear over the four cells. +fn shelter_amount(t: ShelterTaps, wa: u32, wb: u32, wc: u32, wd: u32, world_y: f32, + cfg: vec4) -> f32 { + if (t.live <= 0.0) { + return 0.0; + } + let y_rel = world_y - cfg.x; + let near = mix(shelter_cell(wa, y_rel), shelter_cell(wb, y_rel), t.frac.x); + let far = mix(shelter_cell(wc, y_rel), shelter_cell(wd, y_rel), t.frac.x); + return clamp(mix(near, far, t.frac.y), 0.0, 1.0); +} + struct WetSurface { albedo: vec3, // Gloss weight for `wet_sheen`: 0 dry, ~1 a soaked top face, up to 2 in a terrain puddle (the @@ -61,11 +112,13 @@ fn wet_noise(p: vec2) -> f32 { } // Terrain bonus: puddle patches on flat ground once it is well soaked — darker, and a stronger -// gloss weight so they read as standing water catching the sky. -fn wet_puddles(s: WetSurface, n: vec3, world_pos: vec3, mf: MonkeyFrame) -> WetSurface { +// gloss weight so they read as standing water catching the sky. MONKEY (rainshelter): `exposure` +// as for `wet_surface`, so no puddle forms under cover. +fn wet_puddles(s: WetSurface, n: vec3, world_pos: vec3, exposure: f32, + mf: MonkeyFrame) -> WetSurface { let wet_row = mf.wet_a; var o = s; - if (wet_row.y <= 0.0) { + if (wet_row.y <= 0.0 || exposure <= 0.0) { return o; } let xz = world_pos.xz; @@ -73,7 +126,8 @@ fn wet_puddles(s: WetSurface, n: vec3, world_pos: vec3, mf: MonkeyFram let level = smoothstep(0.94, 0.99, n.y); // Puddles grow with the wetness: the threshold falls as the ground soaks. let edge = mix(0.8, 0.6, smoothstep(0.4, 1.0, wet_row.y)); - let puddle = smoothstep(edge, edge + 0.06, field) * level * smoothstep(0.35, 0.8, wet_row.y); + let puddle = smoothstep(edge, edge + 0.06, field) * level * smoothstep(0.35, 0.8, wet_row.y) + * clamp(exposure, 0.0, 1.0); o.albedo = o.albedo * (1.0 - 0.45 * puddle); o.boost = max(o.boost, 2.0 * puddle); return o; diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index 87f03641..427dd528 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -132,6 +132,11 @@ struct WowLight { points: array, 512>, // MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl). monkey: monkey_frame::MonkeyFrame, + // MONKEY (rainshelter): the rain-occlusion grid (`weather/shelter.rs`): `[origin_x, origin_z, + // 1/cell, active]`, `[base_y, cells per side, 0, 0]`, then one packed word a cell (wet_hook.wgsl). + shelter_hdr: vec4, + shelter_cfg: vec4, + shelter: array, // The interior-prop SH probes, 7 rows per slot (`MAX_PROP_PROBES` = 8192 slots). Only this // shader declares this tail; the other shaders bind the same buffer by its prefix. prop_probes: array, 57344>, @@ -1762,8 +1767,19 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut { // Mod/Mod2x, additive and dry = untouched. let wet_dry = is_interior || interior_fogged || is_rig || is_emissive || is_mod || is_mod2x || (u32(m.clutter_fade.z) & 4u) != 0u; - let wet = wet_hook::wet_surface(rgb, n_lit, in.world_position.xyz, select(1.0, 0.0, wet_dry), - wow_light.monkey); + // MONKEY (rainshelter): nothing gets wet under a roof, porch or bridge (the shelter grid). + var wet_open = 1.0; + if (wow_light.monkey.wet_a.y > 0.0) { + let wet_st = wet_hook::shelter_taps(in.world_position.xyz, wow_light.shelter_hdr, + wow_light.shelter_cfg); + if (wet_st.live > 0.0) { + wet_open = 1.0 - wet_hook::shelter_amount(wet_st, wow_light.shelter[wet_st.idx.x], + wow_light.shelter[wet_st.idx.y], wow_light.shelter[wet_st.idx.z], + wow_light.shelter[wet_st.idx.w], in.world_position.y, wow_light.shelter_cfg); + } + } + let wet = wet_hook::wet_surface(rgb, n_lit, in.world_position.xyz, + select(1.0, 0.0, wet_dry) * wet_open, wow_light.monkey); rgb = wet.albedo; if (wet.boost > 0.0) { rgb = min(rgb + wet_hook::wet_sheen(wet.boost, n_lit, normalize(view.world_position - in.world_position.xyz), diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs index a93b71ff..c67291c7 100644 --- a/crates/benilla-world/src/lighting/global_light.rs +++ b/crates/benilla-world/src/lighting/global_light.rs @@ -729,12 +729,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 27f253b0..5ce93176 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/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl index 9009163e..142c032e 100644 --- a/crates/benilla-world/src/shaders/static_gx.wgsl +++ b/crates/benilla-world/src/shaders/static_gx.wgsl @@ -52,6 +52,11 @@ struct WowLight { points: array, 512>, // MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl). monkey: monkey_frame::MonkeyFrame, + // MONKEY (rainshelter): the rain-occlusion grid (`weather/shelter.rs`): `[origin_x, origin_z, + // 1/cell, active]`, `[base_y, cells per side, 0, 0]`, then one packed word a cell (wet_hook.wgsl). + shelter_hdr: vec4, + shelter_cfg: vec4, + shelter: array, // lighting::prop_probes: 8192 slots of 7 rows; the buffer's later regions are not mirrored. prop_probes: array, 57344>, } @@ -1993,10 +1998,21 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 { if ((in.word & WORD_UNLIT) != 0u) { rgb = folded; } + // MONKEY (rainshelter): nothing gets wet under a roof, porch or bridge (the shelter grid). + var wet_open = 1.0; + if (wow_light.monkey.wet_a.y > 0.0) { + let wet_st = wet_hook::shelter_taps(in.world_position.xyz, wow_light.shelter_hdr, + wow_light.shelter_cfg); + if (wet_st.live > 0.0) { + wet_open = 1.0 - wet_hook::shelter_amount(wet_st, wow_light.shelter[wet_st.idx.x], + wow_light.shelter[wet_st.idx.y], wow_light.shelter[wet_st.idx.z], + wow_light.shelter[wet_st.idx.w], in.world_position.y, wow_light.shelter_cfg); + } + } // MONKEY (wet): rain on sky-exposed surfaces (wet_hook.wgsl); interior, unlit and dry = untouched. let wet = wet_hook::wet_surface(rgb, n_lit, in.world_position.xyz, // MONKEY (fix-wet): lit WMO windows (WORD_WINDOW) are not darkened by rain either. - select(1.0, 0.0, (in.word & (WORD_INTERIOR | WORD_UNLIT | WORD_WINDOW)) != 0u), + select(1.0, 0.0, (in.word & (WORD_INTERIOR | WORD_UNLIT | WORD_WINDOW)) != 0u) * wet_open, wow_light.monkey); rgb = wet.albedo; if (wet.boost > 0.0) { 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 1dcca38b..93f9f539 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,