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:
+6
-1
@@ -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; }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
Reference in New Issue
Block a user