MONKEY (integration): merge graphics-programme-rainshelter

Camera-centred rain-occlusion height map for the wet lane (clean merge).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-25 19:53:18 +01:00
co-authored by Claude Opus 5.5
12 changed files with 896 additions and 18 deletions
+6 -1
View File
@@ -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
@@ -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<u32>(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>) -> 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<f32>,
@@ -256,13 +277,16 @@ fn ripple_hash(p: vec2<f32>) -> vec2<f32> {
q += dot(q, q.yzx + 33.33);
return fract((q.xx + q.yz) * q.zy);
}
fn rain_ripple_normal(world_xz: vec2<f32>, footprint: f32, distance: f32, room: bool) -> vec2<f32> {
fn rain_ripple_normal(world_pos: vec3<f32>, footprint: f32, distance: f32, room: bool) -> vec2<f32> {
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<f32>(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<f32>(0.0);
}
@@ -595,7 +619,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4<f32>, deep: vec4<f32>) -> 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<f32>(-surf_grad.x, 1.0, -surf_grad.y));
if dot(n, to_view) < 0.0 { n = -n; }
+18 -2
View File
@@ -64,6 +64,11 @@ struct WowLight {
points: array<vec4<f32>, 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<f32>,
shelter_cfg: vec4<f32>,
shelter: array<u32, 16384>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(90) var<storage, read> wow_light: WowLight;
@@ -757,9 +762,20 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4<f32> {
// 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;
@@ -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<u32>,
frac: vec2<f32>,
live: f32,
}
fn shelter_taps(world_pos: vec3<f32>, hdr: vec4<f32>, cfg: vec4<f32>) -> ShelterTaps {
var t: ShelterTaps;
t.idx = vec4<u32>(0u);
t.frac = vec2<f32>(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<u32>(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>) -> 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<f32>,
// 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>) -> 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<f32>, world_pos: vec3<f32>, 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<f32>, world_pos: vec3<f32>, 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<f32>, world_pos: vec3<f32>, 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;
@@ -132,6 +132,11 @@ struct WowLight {
points: array<vec4<f32>, 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<f32>,
shelter_cfg: vec4<f32>,
shelter: array<u32, 16384>,
// 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<vec4<f32>, 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),
@@ -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::<LightStd430>() as u64
}
+1
View File
@@ -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};
@@ -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
@@ -52,6 +52,11 @@ struct WowLight {
points: array<vec4<f32>, 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<f32>,
shelter_cfg: vec4<f32>,
shelter: array<u32, 16384>,
// lighting::prop_probes: 8192 slots of 7 rows; the buffer's later regions are not mirrored.
prop_probes: array<vec4<f32>, 57344>,
}
@@ -1993,10 +1998,21 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
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) {
+2
View File
@@ -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)
}
}
+740
View File
@@ -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<f32>,
/// 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<f32>) -> 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<u16> {
let c = GRID as f32 / 2.0 - 0.5;
let mut order: Vec<u16> = (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<f32>,
raw: Vec<f32>,
eroded: Vec<f32>,
sampled: Vec<bool>,
pending: usize,
refresh: usize,
order: Vec<u16>,
/// The packed words, shared with the render world.
pub cells: Arc<Vec<u32>>,
/// 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<f32>, 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<Vec<u32>>,
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<MonkeyFrame>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
spatial: SpatialQuery,
mut grid: ResMut<ShelterGrid>,
mut out: ResMut<ShelterExtract>,
) {
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<RenderQueue>,
buffer: Option<Res<SharedLightBuffer>>,
data: Option<Res<ShelterExtract>>,
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::<ShelterGrid>()
.init_resource::<ShelterExtract>()
.add_plugins(ExtractResourcePlugin::<ShelterExtract>::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<f32> {
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
);
}
}
+1 -1
View File
@@ -85,7 +85,7 @@ fn env_overrides() -> &'static EnvOverrides {
})
}
fn wetness_tick(
pub(super) fn wetness_tick(
time: Res<Time<Real>>,
state: Res<WeatherState>,
cvar: Res<RainSurfaces>,