From 3415538908e2053a5ab96bf6349cfc5a3212499f Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 01:21:19 +0100 Subject: [PATCH 01/87] MONKEY (p0): MonkeyFrame block appended to the shared light buffer 16 vec4 rows (256 B) after the point-light table, so no earlier offset moves (per-frame blob 8528 -> 8784 B, size test updated). Rust side: the `lighting::MonkeyFrame` resource any system may write, packed each frame by `global_light::pack_monkey_frame` (which also fills time_of_day / night). WGSL side: `benilla::monkey_frame::MonkeyFrame`, declared as `monkey` after `points` in every WowLight mirror (terrain, wow_model, static_gx, liquid, enhanced_water, wow_effect, wdl). All zero = no visual change. Layout in LIGHTING.md "Invariants". Co-Authored-By: Claude Opus 5.5 --- LIGHTING.md | 24 ++- crates/benilla-assets/src/materials.rs | 3 + .../src/shaders/enhanced_water.wgsl | 4 + crates/benilla-assets/src/shaders/liquid.wgsl | 6 + .../src/shaders/monkey_frame.wgsl | 21 ++ .../benilla-assets/src/shaders/terrain.wgsl | 4 + crates/benilla-assets/src/shaders/wdl.wgsl | 8 + .../benilla-assets/src/shaders/wow_model.wgsl | 4 + .../src/lighting/global_light.rs | 28 ++- crates/benilla-world/src/lighting/mod.rs | 2 + .../src/lighting/monkey_frame.rs | 190 ++++++++++++++++++ .../benilla-world/src/shaders/static_gx.wgsl | 4 + .../benilla-world/src/shaders/wow_effect.wgsl | 7 + 13 files changed, 301 insertions(+), 4 deletions(-) create mode 100644 crates/benilla-assets/src/shaders/monkey_frame.wgsl create mode 100644 crates/benilla-world/src/lighting/monkey_frame.rs diff --git a/LIGHTING.md b/LIGHTING.md index 2122d3df..59e0f2f1 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -52,8 +52,28 @@ is its own module, documented in `WATER.md`. ## Invariants worth knowing before editing -- The shared light buffer stays 8528 bytes and the torch table 6416 bytes; both are mirrored in - three shaders and pinned by tests. +- The shared light buffer's per-frame blob is 8784 bytes and the torch table 6416 bytes; both are + mirrored in the shaders and pinned by tests. The blob is 21 header rows (336 B), the 256-slot + point-light table (8192 B), then the MONKEY **MonkeyFrame** block (16 rows, 256 B; nothing before + it moves). Every `WowLight` mirror (terrain, wow_model, static_gx, liquid, enhanced_water, + wow_effect, wdl) declares it as `monkey: monkey_frame::MonkeyFrame` after `points`: + + | row | x | y | z | w | + |---|---|---|---|---| + | `fog0` | height_fog_density | height_fog_height | height_fog_falloff | curve_blend | + | `fog1` | sun_fog r | g | b | sun_fog_strength | + | `fog2` | end_fog r | g | b | end_fog_distance | + | `fog3` | fog_model (0 classic, 1 modern) | sun_fog_angle | 0 | 0 | + | `wind0` | dir_x | dir_y | speed | gust | + | `wind1` | time_s | sway_strength | grass_strength | tree_strength | + | `wet0` | rain_rate | wetness | ripple_time_s | snow | + | `misc` | bender_count | time_of_day 0..1 | night 0..1 | 0 | + | `benders[8]` | world x | world y | world z | radius | + + 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 + the point table and fills `time_of_day` / `night` itself. All zero = no visual change. - World lights use upstream's `WorldPointLight`, never Bevy's `PointLight`. - WGSL only fails at pipeline creation, so a shader edit is verified by running a world scene, not by `cargo check`. diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs index 18aa1682..08878c86 100644 --- a/crates/benilla-assets/src/materials.rs +++ b/crates/benilla-assets/src/materials.rs @@ -29,6 +29,9 @@ pub fn register_shaders(app: &mut App) { // (loaded eagerly so `#import benilla::shadow_hook` resolves in terrain/model/static_gx). This is // the ONE place the realtime directional-shadow term lives; the receivers just call into it. load_shader_library!(app, "shaders/shadow_hook.wgsl"); + // MONKEY (p0 MonkeyFrame): `benilla::monkey_frame`, the struct every `WowLight` mirror declares + // after its point table (LIGHTING.md "Invariants"). + load_shader_library!(app, "shaders/monkey_frame.wgsl"); // MONKEY (enhanced water): the optional water module, `benilla::enhanced_water` (WATER.md). load_shader_library!(app, "shaders/enhanced_water.wgsl"); } diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl index 667e7459..ac4a27c2 100644 --- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl +++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl @@ -20,6 +20,8 @@ // 106 the opaque scene COLOUR, copied beside the depth: what the refraction looks through #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 @group(#{MATERIAL_BIND_GROUP}) @binding(103) var scene_depth: texture_2d; @group(#{MATERIAL_BIND_GROUP}) @binding(106) var scene_colour: texture_2d; @@ -55,6 +57,8 @@ struct WaterLight { wmo_fog_params: vec4, // 19 point_count: vec4, // 20 live count + point-light controls points: array, 512>, // 21+ position/range, colour/lane pairs + // MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl). + monkey: monkey_frame::MonkeyFrame, }; @group(#{MATERIAL_BIND_GROUP}) @binding(105) var water_light: WaterLight; diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl index 1a9d72e7..293cd96f 100644 --- a/crates/benilla-assets/src/shaders/liquid.wgsl +++ b/crates/benilla-assets/src/shaders/liquid.wgsl @@ -30,6 +30,8 @@ } // MONKEY (enhanced water): the optional water module - see enhanced_water.wgsl and WATER.md. #import benilla::enhanced_water::{water_active, water_swell, enhanced_water, WaterFragment} +// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table. +#import benilla::monkey_frame @group(#{MATERIAL_BIND_GROUP}) @binding(100) var frames: texture_2d_array; @group(#{MATERIAL_BIND_GROUP}) @binding(101) var frames_samp: sampler; @@ -60,6 +62,10 @@ struct WowLight { _grade: vec4, // 17 wmo_fog_color: vec4, // 18 rgb = interior fog (block 2); w = enable wmo_fog_params: vec4, // 19 x = start yd; y = end yd + _point_count: vec4, // 20 point-light count, unread here + _points: array, 512>, // 21+ point-light table, unread here + // MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl). + monkey: monkey_frame::MonkeyFrame, }; @group(#{MATERIAL_BIND_GROUP}) @binding(90) var wow_light: WowLight; diff --git a/crates/benilla-assets/src/shaders/monkey_frame.wgsl b/crates/benilla-assets/src/shaders/monkey_frame.wgsl new file mode 100644 index 00000000..ea1780b3 --- /dev/null +++ b/crates/benilla-assets/src/shaders/monkey_frame.wgsl @@ -0,0 +1,21 @@ +#define_import_path benilla::monkey_frame + +// MONKEY (p0 MonkeyFrame): the graphics programme's per-frame block, 16 rows (256 B) appended to +// the shared light buffer right after the point-light table (`lighting::global_light`, packed from +// the `MonkeyFrame` resource in `lighting/monkey_frame.rs`). Every receiver's `WowLight` mirror +// declares it as `monkey: monkey_frame::MonkeyFrame` after `points`, so the offsets of everything +// before it never move. All zero = no visual change: every reader treats zero as "feature off". +// +// Coordinates are Bevy world space (Y up, 1 unit = 1 yd), the space of the receivers' +// `world_position`: WoW `(x, y, z)` is Bevy `(-y, z, -x)` (`benilla_assets::coords::wow_to_bevy`). +struct MonkeyFrame { + fog0: vec4, // height_fog_density, height_fog_height (world Y, yd), height_fog_falloff, curve_blend + fog1: vec4, // sun_fog_rgb (gamma 0..1), sun_fog_strength + fog2: vec4, // end_fog_rgb (gamma 0..1), end_fog_distance (yd) + fog3: vec4, // fog_model (0 classic, 1 modern), sun_fog_angle (cos), 0, 0 + wind0: vec4, // dir_x, dir_y (unit, world XZ: .x = world x, .y = world z), speed (yd/s), gust 0..1 + wind1: vec4, // time_s, sway_strength, grass_strength, tree_strength + wet0: vec4, // rain_rate 0..1, wetness 0..1, ripple_time_s, snow 0..1 + misc: vec4, // bender_count, time_of_day 0..1 (packer), night 0..1 (packer), 0 + benders: array, 8>, // world xyz (Bevy space) + radius (yd); the first bender_count are live +} diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl index dbbd71d5..c5394a0b 100644 --- a/crates/benilla-assets/src/shaders/terrain.wgsl +++ b/crates/benilla-assets/src/shaders/terrain.wgsl @@ -18,6 +18,8 @@ } // MONKEY (shadow hook): the realtime directional-shadow term (fetch + edge/night fade) lives here. #import benilla::shadow_hook +// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table. +#import benilla::monkey_frame @group(#{MATERIAL_BIND_GROUP}) @binding(100) var layer_array: texture_2d_array; @group(#{MATERIAL_BIND_GROUP}) @binding(104) var alpha_array: texture_2d_array; @@ -56,6 +58,8 @@ struct WowLight { // reach in yards) for an INTERIOR one. Terrain consumes only the exterior half. point_count: vec4, points: array, 512>, + // MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl). + monkey: monkey_frame::MonkeyFrame, }; @group(#{MATERIAL_BIND_GROUP}) @binding(90) var wow_light: WowLight; diff --git a/crates/benilla-assets/src/shaders/wdl.wgsl b/crates/benilla-assets/src/shaders/wdl.wgsl index 69d4e93b..ea85aa3e 100644 --- a/crates/benilla-assets/src/shaders/wdl.wgsl +++ b/crates/benilla-assets/src/shaders/wdl.wgsl @@ -17,6 +17,9 @@ mesh_view_bindings::view, } +// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table. +#import benilla::monkey_frame + /// How far the band reaches inside the far-clip wall: the reference's far-band near plane /// `farclip - 33.0` (`[0x8101b0]`, about one WDL outer cell). const WDL_OVERLAP: f32 = 33.0; @@ -34,6 +37,11 @@ struct WowLight { _light_spec: vec4, // 3 fog_color: vec4, // 4 rgb = Light.dbc row 7 (gamma 0..1); w = enable (>0.5 ⇒ blend) fog_params: vec4, // 5 x/y = the SCENE fog start/end yd (unread here — the hull has its own pair); z = the signed directional-shadow weight (MONKEY moon shadows: +sun / -moon; unread here); w = farclip wall (0 ⇒ off) + // MONKEY (p0 MonkeyFrame): rows 6-20 and the point table, unread here, so the block lines up. + _rows_6_20: array, 15>, + _points: array, 512>, + // MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl). + monkey: monkey_frame::MonkeyFrame, }; @group(#{MATERIAL_BIND_GROUP}) @binding(90) var w: WowLight; diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index 07705ba5..0f211ca6 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -20,6 +20,8 @@ } // MONKEY (shadow hook): the realtime directional-shadow term (fetch + edge/night fade) lives here. #import benilla::shadow_hook +// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table. +#import benilla::monkey_frame // bevy_pbr 0.18.1's `forward_io::FragmentOutput`. No depth output: a fragment depth write costs // the pipeline early-Z, so the sky lane pins its depth in the vertex stage. @@ -112,6 +114,8 @@ struct WowLight { // packed reach is always ≥ 1 yd and can never be mistaken for the exterior 0. point_count: vec4, points: array, 512>, + // MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl). + monkey: monkey_frame::MonkeyFrame, // The interior-prop SH probes, 7 rows per slot (`MAX_PROP_PROBES` = 8192 slots). Only this // shader declares this tail; the other shaders bind the same buffer by its prefix. prop_probes: array, 57344>, diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs index b3373de0..06d5d90b 100644 --- a/crates/benilla-world/src/lighting/global_light.rs +++ b/crates/benilla-world/src/lighting/global_light.rs @@ -66,6 +66,9 @@ use crate::view::WorldCamera; struct LightStd430 { rows: [[f32; 4]; LIGHT_HEADER_ROWS], points: [[f32; 4]; 2 * MAX_POINT_LIGHTS], + // MONKEY (p0 MonkeyFrame): the programme block, appended AFTER the point table so no earlier + // offset moves (8528 -> 8784 B); packed by [`pack_monkey_frame`] from [`super::MonkeyFrame`]. + monkey: [[f32; 4]; super::monkey_frame::MONKEY_FRAME_ROWS], } /// Header row count of the layout above (rows 0..=20), which every light blob sizes against. @@ -615,6 +618,7 @@ impl Default for WowLightData { Self(LightStd430 { rows: [[0.0; 4]; 21], points: [[0.0; 4]; 2 * MAX_POINT_LIGHTS], + monkey: [[0.0; 4]; super::monkey_frame::MONKEY_FRAME_ROWS], }) } } @@ -644,6 +648,8 @@ pub(super) fn register(app: &mut App) { // MONKEY (spellLightGain): and the one on spell-effect lights, which overrides it. .init_resource::() .init_resource::() + // MONKEY (p0 MonkeyFrame): the per-frame programme parameters any system may write. + .init_resource::() .add_plugins(ExtractResourcePlugin::::default()) .add_plugins(ExtractResourcePlugin::::default()) .add_plugins(ExtractResourcePlugin::::default()) @@ -656,7 +662,8 @@ pub(super) fn register(app: &mut App) { // a light that has stood for a frame is packed on this frame's verdict. Its writes go // through `Commands`, so a NEWLY spawned light is still packed on the fail-safe // fallback for one frame — see that fallback's note in `build_light_data`. - (classify_light_lanes, build_light_data) + // MONKEY (p0 MonkeyFrame): the programme block is packed right after the table. + (classify_light_lanes, build_light_data, pack_monkey_frame) .chain() .after(bevy::transform::TransformSystems::Propagate) .after(super::update_time_lighting), @@ -699,6 +706,22 @@ pub(super) fn per_frame_blob_bytes() -> u64 { std::mem::size_of::() as u64 } +/// MONKEY (p0 MonkeyFrame): copies [`super::MonkeyFrame`] into the block after the point table, +/// plus the two clock fields the packer owns (`misc.y` time of day 0..1, `misc.z` night 0..1, the +/// same dusk ramp `nightGain` uses). Writes through `ResMut` only when a row moved. +fn pack_monkey_frame( + frame: Res, + clock: Res, + light: Res, + mut data: ResMut, +) { + let night = 1.0 - sun_shadow_strength(light.celestial_dir.y); + let packed = frame.pack(clock.minute as f32 / 1440.0, night); + if data.0.monkey != packed { + data.0.monkey = packed; + } +} + /// Pack the resolved [`WowLighting`] (+ the global fog-disable toggle and the view farclip) into the /// std430 blob. The `.w` lanes carry the faithful invariants the shaders expect (Mod2x 1.0, clamp on, /// terrain shininess 20, fog-enable, farclip wall); the model SH coeffs and both water swatches are @@ -2626,7 +2649,8 @@ mod tests { assert_eq!(MAX_LIVE_POINT_LIGHTS, 255, "255 is EXT_SEL_EMPTY in the three shaders"); assert_eq!(MAX_LIVE_POINT_LIGHTS, MAX_POINT_LIGHTS - 1); // 21 header rows + 2 x 256 point rows, 16 B each. - assert_eq!(per_frame_blob_bytes(), 8528, "the mirrored blob must not change size"); + // MONKEY (p0 MonkeyFrame): 8528 + the 256-byte programme block. + assert_eq!(per_frame_blob_bytes(), 8784, "the mirrored blob must not change size"); } /// MONKEY (enclosed day floor): `interiorDaylight` rides the FRACTION of the interior lane's diff --git a/crates/benilla-world/src/lighting/mod.rs b/crates/benilla-world/src/lighting/mod.rs index 027a4ce0..81f80ad3 100644 --- a/crates/benilla-world/src/lighting/mod.rs +++ b/crates/benilla-world/src/lighting/mod.rs @@ -11,6 +11,8 @@ mod daylight; // MONKEY (daylight fixtures): the sun as an interior-lane light i mod daynight; // the two sun directions + day/night interp + the dawn/dusk warp curve mod flicker; // MONKEY (flame flicker): the per-light fire wobble folded in at pack time mod global_light; // the one shared global-light storage buffer (replaces the per-material push) +mod monkey_frame; // MONKEY (p0 MonkeyFrame): the programme's per-frame block after the point table +pub use monkey_frame::{FogModel, MonkeyFrame, MAX_BENDERS, MONKEY_FRAME_ROWS}; mod lava_light; // MONKEY (lava light): magma surface fixtures and their independent gain pub use lava_light::{LavaLight, LavaLightGain}; mod prop_probes; // the per-instance interior-prop SH probe table (slot ↔ MeshTag payload) diff --git a/crates/benilla-world/src/lighting/monkey_frame.rs b/crates/benilla-world/src/lighting/monkey_frame.rs new file mode 100644 index 00000000..3ca56f7d --- /dev/null +++ b/crates/benilla-world/src/lighting/monkey_frame.rs @@ -0,0 +1,190 @@ +//! MONKEY (p0 MonkeyFrame): the graphics programme's per-frame parameter block. +//! +//! One resource, [`MonkeyFrame`], that any system may write (fog model, wind, wetness, benders). +//! The light packer (`global_light::pack_monkey_frame`) copies it every frame into 16 rows +//! appended to the shared light buffer right after the point-light table, so the offsets of every +//! row before it never move. The WGSL mirror is `benilla::monkey_frame::MonkeyFrame` +//! (`benilla-assets/src/shaders/monkey_frame.wgsl`); every receiver's `WowLight` struct declares +//! it as its `monkey` member. +//! +//! All zero (the default) is "every feature off": nothing reads a zero field as a change, so the +//! image is the same as before the block existed. +//! +//! Layout (16 × `vec4`, 256 B): +//! +//! | row | xyz / x,y,z | w | +//! |---|---|---| +//! | 0 `fog0` | height_fog_density, height_fog_height, height_fog_falloff | curve_blend | +//! | 1 `fog1` | sun_fog_rgb | sun_fog_strength | +//! | 2 `fog2` | end_fog_rgb | end_fog_distance | +//! | 3 `fog3` | fog_model (0 classic, 1 modern), sun_fog_angle, 0 | 0 | +//! | 4 `wind0` | dir_x, dir_y, speed | gust | +//! | 5 `wind1` | time_s, sway_strength, grass_strength | tree_strength | +//! | 6 `wet0` | rain_rate, wetness, ripple_time_s | snow | +//! | 7 `misc` | bender_count, time_of_day 0..1, night 0..1 | 0 | +//! | 8-15 `benders` | world x, y, z | radius | +//! +//! Coordinates are Bevy world space (Y up, 1 unit = 1 yd), the space the receivers' +//! `world_position` is in: WoW `(x, y, z)` → Bevy `(-y, z, -x)` ([`benilla_assets::coords`]). +//! The wind direction is a unit vector in the world XZ plane: `dir_x` = world x, `dir_y` = world z. +//! `time_of_day` and `night` are filled by the packer from the game clock and the celestial sun; +//! every other field belongs to the lane that owns the feature. + +use bevy::prelude::*; + +/// Rows in the appended block. +pub const MONKEY_FRAME_ROWS: usize = 16; + +/// How many benders (grass/foliage pushers: the player and nearby units) the block carries. +pub const MAX_BENDERS: usize = 8; + +/// The fog model selector, `fog3.x`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum FogModel { + /// Today's linear 1.12 fog, byte-identical. + #[default] + Classic, + /// The FOG lane's modern model (height / sun / end-colour / curve fog). + Modern, +} + +/// The per-frame programme parameters. Write the fields your lane owns through +/// `ResMut`; leave the rest alone. See the module docs for the packed layout. +#[derive(Resource, Clone, Copy, Debug, Default, PartialEq)] +pub struct MonkeyFrame { + // ── FOG lane (rows 0-3) ── + pub fog_model: FogModel, + pub height_fog_density: f32, + /// World Y (Bevy space, yd) of the height-fog plane. + pub height_fog_height: f32, + pub height_fog_falloff: f32, + pub fog_curve_blend: f32, + /// Gamma 0..1. + pub sun_fog_rgb: [f32; 3], + pub sun_fog_strength: f32, + /// Cosine threshold of the sun-fog lobe. + pub sun_fog_angle: f32, + /// Gamma 0..1. + pub end_fog_rgb: [f32; 3], + pub end_fog_distance: f32, + // ── WIND lane (rows 4-5) ── + /// Unit vector in the world XZ plane (`[x, z]`). + pub wind_dir: [f32; 2], + /// Yards per second. + pub wind_speed: f32, + pub wind_gust: f32, + /// The wind clock in seconds (the lane owns it, so a capture can pin it). + pub wind_time_s: f32, + pub sway_strength: f32, + pub grass_strength: f32, + pub tree_strength: f32, + // ── WET lane (row 6) ── + pub rain_rate: f32, + pub wetness: f32, + pub ripple_time_s: f32, + pub snow: f32, + // ── benders (rows 7.x, 8-15) ── + /// Bender slots; only the first `min(bender_count, MAX_BENDERS)` are packed. `[x, y, z, radius]` in + /// Bevy world space. + pub benders: [[f32; 4]; MAX_BENDERS], + pub bender_count: u32, +} + +impl MonkeyFrame { + /// The 16 packed rows. `time_of_day` (0..1) and `night` (0..1) are the packer's own inputs. + pub fn pack(&self, time_of_day: f32, night: f32) -> [[f32; 4]; MONKEY_FRAME_ROWS] { + let mut rows = [[0.0f32; 4]; MONKEY_FRAME_ROWS]; + let model = match self.fog_model { + FogModel::Classic => 0.0, + FogModel::Modern => 1.0, + }; + rows[0] = [ + self.height_fog_density, + self.height_fog_height, + self.height_fog_falloff, + self.fog_curve_blend, + ]; + let s = self.sun_fog_rgb; + rows[1] = [s[0], s[1], s[2], self.sun_fog_strength]; + let e = self.end_fog_rgb; + rows[2] = [e[0], e[1], e[2], self.end_fog_distance]; + rows[3] = [model, self.sun_fog_angle, 0.0, 0.0]; + rows[4] = [self.wind_dir[0], self.wind_dir[1], self.wind_speed, self.wind_gust]; + rows[5] = [self.wind_time_s, self.sway_strength, self.grass_strength, self.tree_strength]; + rows[6] = [self.rain_rate, self.wetness, self.ripple_time_s, self.snow]; + let n = (self.bender_count as usize).min(MAX_BENDERS); + rows[7] = [n as f32, time_of_day, night, 0.0]; + rows[8..8 + n].copy_from_slice(&self.benders[..n]); + rows + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_default_frame_packs_to_zero_but_the_clock() { + let rows = MonkeyFrame::default().pack(0.0, 0.0); + assert!(rows.iter().flatten().all(|v| *v == 0.0)); + let rows = MonkeyFrame::default().pack(0.5, 1.0); + assert_eq!(rows[7], [0.0, 0.5, 1.0, 0.0]); + } + + #[test] + fn fields_land_in_their_documented_rows() { + let f = MonkeyFrame { + fog_model: FogModel::Modern, + height_fog_density: 1.0, + height_fog_height: 2.0, + height_fog_falloff: 3.0, + fog_curve_blend: 4.0, + sun_fog_rgb: [5.0, 6.0, 7.0], + sun_fog_strength: 8.0, + end_fog_rgb: [9.0, 10.0, 11.0], + end_fog_distance: 12.0, + sun_fog_angle: 13.0, + wind_dir: [14.0, 15.0], + wind_speed: 16.0, + wind_gust: 17.0, + wind_time_s: 18.0, + sway_strength: 19.0, + grass_strength: 20.0, + tree_strength: 21.0, + rain_rate: 22.0, + wetness: 23.0, + ripple_time_s: 24.0, + snow: 25.0, + benders: [[26.0, 27.0, 28.0, 29.0]; MAX_BENDERS], + bender_count: 20, // clamped to MAX_BENDERS + }; + let r = f.pack(0.25, 0.75); + assert_eq!(r[0], [1.0, 2.0, 3.0, 4.0]); + assert_eq!(r[1], [5.0, 6.0, 7.0, 8.0]); + assert_eq!(r[2], [9.0, 10.0, 11.0, 12.0]); + assert_eq!(r[3], [1.0, 13.0, 0.0, 0.0]); + assert_eq!(r[4], [14.0, 15.0, 16.0, 17.0]); + assert_eq!(r[5], [18.0, 19.0, 20.0, 21.0]); + assert_eq!(r[6], [22.0, 23.0, 24.0, 25.0]); + assert_eq!(r[7], [8.0, 0.25, 0.75, 0.0]); + assert_eq!(r[15], [26.0, 27.0, 28.0, 29.0]); + } + + /// The WGSL mirror declares the same 16 rows; a member added on one side only would shift + /// every bender. + #[test] + fn the_wgsl_mirror_has_sixteen_rows() { + let src = include_str!("../../../benilla-assets/src/shaders/monkey_frame.wgsl"); + let body = src.split_once("struct MonkeyFrame {").unwrap().1; + let body = body.split_once("\n}").unwrap().0; + let mut rows = 0; + for line in body.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with("//")) { + if line.contains("array, 8>") { + rows += 8; + } else if line.contains("vec4") { + rows += 1; + } + } + assert_eq!(rows, MONKEY_FRAME_ROWS); + } +} diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl index ebe78d5a..0850dc75 100644 --- a/crates/benilla-world/src/shaders/static_gx.wgsl +++ b/crates/benilla-world/src/shaders/static_gx.wgsl @@ -10,6 +10,8 @@ } // MONKEY (shadow hook): the realtime directional-shadow term (fetch + edge/night fade) lives here. #import benilla::shadow_hook +// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table. +#import benilla::monkey_frame // Group 0 is Bevy's standard mesh-view bind group (view matrices, directional-light records and // the shadow textures the retained pass reads). @@ -40,6 +42,8 @@ struct WowLight { // yards. wow_model.wgsl owns the full note. point_count: vec4, points: array, 512>, + // MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl). + monkey: monkey_frame::MonkeyFrame, // lighting::prop_probes: 8192 slots of 7 rows; the buffer's later regions are not mirrored. prop_probes: array, 57344>, } diff --git a/crates/benilla-world/src/shaders/wow_effect.wgsl b/crates/benilla-world/src/shaders/wow_effect.wgsl index f6654dd9..e3921b8f 100644 --- a/crates/benilla-world/src/shaders/wow_effect.wgsl +++ b/crates/benilla-world/src/shaders/wow_effect.wgsl @@ -12,6 +12,8 @@ // - BLEND_MOD2X: (rgb, 1) under (Dst, Src) = `2·src·dst`, rain's state; reads no alpha. #import bevy_render::view::View +// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table. +#import benilla::monkey_frame // Prefix of `lighting::global_light`'s buffer; keep in sync with wow_model.wgsl's copy. struct WowLight { @@ -33,6 +35,11 @@ struct WowLight { sh_c16: vec4, _water: array, 4>, grade: vec4, + // MONKEY (p0 MonkeyFrame): rows 18-20 and the point table, unread here, so the block lines up. + _rows_18_20: array, 3>, + _points: array, 512>, + // MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl). + monkey: monkey_frame::MonkeyFrame, }; @group(0) @binding(0) var view: View; From fc99f2c858be168d4a6ff06311498359d0f27ac3 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 01:33:52 +0100 Subject: [PATCH 02/87] MONKEY (p0): fog_hook.wgsl, one distance-fog law for every receiver `benilla::fog_hook` (fog_linear / fog_sample / apply_fog) replaces the seven hand-rolled copies of the linear planar-eye-Z fog: terrain, wow_model, static_gx, liquid, enhanced_water (factor form), wow_effect (scene fog; the rain streak fade uses fog_linear) and wdl (the hull's own 0..1 yd pair). Pure functions, values passed as arguments, same expressions in the same order, so the classic result is bit-identical. `fog_sample` branches on MonkeyFrame.fog_model (fog3.x) as the FOG lane's extension point, currently returning the classic result. wow_effect gains a world_pos varying for it. Co-Authored-By: Claude Opus 5.5 --- LIGHTING.md | 4 ++ crates/benilla-assets/src/materials.rs | 2 + .../src/shaders/enhanced_water.wgsl | 7 ++- .../benilla-assets/src/shaders/fog_hook.wgsl | 63 +++++++++++++++++++ crates/benilla-assets/src/shaders/liquid.wgsl | 8 ++- .../benilla-assets/src/shaders/terrain.wgsl | 8 ++- crates/benilla-assets/src/shaders/wdl.wgsl | 6 ++ .../benilla-assets/src/shaders/wow_model.wgsl | 8 ++- .../benilla-world/src/shaders/static_gx.wgsl | 8 ++- .../benilla-world/src/shaders/wow_effect.wgsl | 16 +++-- 10 files changed, 111 insertions(+), 19 deletions(-) create mode 100644 crates/benilla-assets/src/shaders/fog_hook.wgsl diff --git a/LIGHTING.md b/LIGHTING.md index 59e0f2f1..27a804c4 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -35,6 +35,10 @@ is its own module, documented in `WATER.md`. ## Where the code lives - **Shaders**: `crates/benilla-assets/src/shaders/shadow_hook.wgsl` (shared shadow sampling), + `fog_hook.wgsl` (MONKEY p0: the ONE distance-fog law — `fog_linear`, `fog_sample`, `apply_fog`; + terrain, wow_model, static_gx, liquid, enhanced_water, wow_effect and wdl all call it; classic is + bit-identical to the old per-shader copies, and `MonkeyFrame.fog_model` = 1 is the FOG lane's + extension point), `monkey_frame.wgsl` (the MonkeyFrame struct), `crates/benilla-world/src/shaders/torch_depth.wgsl`, and the lighting lanes inside `static_gx.wgsl`, `wow_model.wgsl` and `terrain.wgsl`. The three receivers mirror each other; the comments say where. diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs index 08878c86..a2254aa8 100644 --- a/crates/benilla-assets/src/materials.rs +++ b/crates/benilla-assets/src/materials.rs @@ -32,6 +32,8 @@ pub fn register_shaders(app: &mut App) { // MONKEY (p0 MonkeyFrame): `benilla::monkey_frame`, the struct every `WowLight` mirror declares // after its point table (LIGHTING.md "Invariants"). load_shader_library!(app, "shaders/monkey_frame.wgsl"); + // MONKEY (p0 fog hook): `benilla::fog_hook`, the one distance-fog law all seven receivers call. + load_shader_library!(app, "shaders/fog_hook.wgsl"); // MONKEY (enhanced water): the optional water module, `benilla::enhanced_water` (WATER.md). load_shader_library!(app, "shaders/enhanced_water.wgsl"); } diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl index ac4a27c2..95eb1183 100644 --- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl +++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl @@ -22,6 +22,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 (p0 fog hook): the one distance-fog law every receiver calls. +#import benilla::fog_hook @group(#{MATERIAL_BIND_GROUP}) @binding(103) var scene_depth: texture_2d; @group(#{MATERIAL_BIND_GROUP}) @binding(106) var scene_colour: texture_2d; @@ -86,8 +88,9 @@ fn water_fog(world_pos: vec3, room_fog: u32) -> vec4 { } if colour.w <= 0.5 { return vec4(colour.rgb, 1.0); } let eye_z = -(view.view_from_world * vec4(world_pos, 1.0)).z; - let factor = clamp((span.y - eye_z) / max(span.y - span.x, 0.001), 0.0, 1.0); - return vec4(colour.rgb, factor); + // MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl), as a factor; classic is bit-identical. + return fog_hook::fog_sample(colour.rgb, span, eye_z, world_pos, view.world_position, true, + water_light.monkey); } // True where this surface takes the Enhanced path: water (not magma/slime), a tier above diff --git a/crates/benilla-assets/src/shaders/fog_hook.wgsl b/crates/benilla-assets/src/shaders/fog_hook.wgsl new file mode 100644 index 00000000..e931ccf9 --- /dev/null +++ b/crates/benilla-assets/src/shaders/fog_hook.wgsl @@ -0,0 +1,63 @@ +#define_import_path benilla::fog_hook + +// MONKEY (p0 fog hook): the ONE place the distance fog is computed. +// +// Every receiver — terrain, wow_model, static_gx, liquid, enhanced_water, wow_effect, wdl — calls +// in here instead of hand-rolling the fog law, so a new fog model touches this file only (the +// `shadow_hook.wgsl` pattern). Pure functions: the receivers bind the shared light buffer at +// different slots (material binding 90, group 2 binding 0, group 1 binding 2, binding 105), so +// every value arrives as an argument and this file owns no bindings. +// +// The classic law is the reference's GL_LINEAR fog on PLANAR eye-Z in gamma space (terrain.wgsl +// has the full note): `factor = clamp((end - eye_z) / max(end - start, 0.001), 0, 1)`, then +// `mix(fog_rgb, rgb, factor)`. The expressions below are the ones the receivers carried, in the +// same order, so the classic result is bit-identical. +// +// Extension point for the FOG lane: `fog_sample` branches on `mf.fog3.x` (`MonkeyFrame.fog_model`, +// 0 classic / 1 modern). The modern arm currently returns the classic result. + +#import benilla::monkey_frame::MonkeyFrame + +// The classic survival factor: 1 = no fog, 0 = fully fogged. +fn fog_linear(eye_z: f32, span: vec2) -> f32 { + let denom = max(span.y - span.x, 0.001); + return clamp((span.y - eye_z) / denom, 0.0, 1.0); +} + +// The fog a surface stands in: rgb = the fog colour, w = how much of the surface survives (1 = no +// fog). `fog_rgb` is the colour the caller's policy picked; `scene_colour` is true when that is the +// scene's own fog colour (false for the forced black/white/grey policies of additive, Mod and Mod2x +// batches, which a new model may attenuate but must not recolour). `span` = (start, end) yd, +// `eye_z` = planar eye depth (yd), `world_pos` / `camera_pos` in Bevy world space. +fn fog_sample( + fog_rgb: vec3, + span: vec2, + eye_z: f32, + world_pos: vec3, + camera_pos: vec3, + scene_colour: bool, + mf: MonkeyFrame, +) -> vec4 { + let classic = vec4(fog_rgb, fog_linear(eye_z, span)); + if (mf.fog3.x > 0.5) { + // FOG lane: the modern model (height / sun / end-colour / curve fog) goes here, reading + // mf.fog0..fog3. Until it lands, the modern arm is the classic law. + return classic; + } + return classic; +} + +// Fog `rgb` in place: `mix(fog colour, rgb, survival)`. Same arguments as `fog_sample`. +fn apply_fog( + rgb: vec3, + fog_rgb: vec3, + span: vec2, + eye_z: f32, + world_pos: vec3, + camera_pos: vec3, + scene_colour: bool, + mf: MonkeyFrame, +) -> vec3 { + let f = fog_sample(fog_rgb, span, eye_z, world_pos, camera_pos, scene_colour, mf); + return mix(f.rgb, rgb, f.w); +} diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl index 293cd96f..56bfd3e7 100644 --- a/crates/benilla-assets/src/shaders/liquid.wgsl +++ b/crates/benilla-assets/src/shaders/liquid.wgsl @@ -32,6 +32,8 @@ #import benilla::enhanced_water::{water_active, water_swell, enhanced_water, WaterFragment} // MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table. #import benilla::monkey_frame +// MONKEY (p0 fog hook): the one distance-fog law every receiver calls. +#import benilla::fog_hook @group(#{MATERIAL_BIND_GROUP}) @binding(100) var frames: texture_2d_array; @group(#{MATERIAL_BIND_GROUP}) @binding(101) var frames_samp: sampler; @@ -132,9 +134,9 @@ fn apply_fog(rgb: vec3, world_pos: vec3, room_fog: u32) -> vec3 { return rgb; } let eye_z = -(view.view_from_world * vec4(world_pos, 1.0)).z; - let denom = max(fog_span.y - fog_span.x, 0.001); - let factor = clamp((fog_span.y - eye_z) / denom, 0.0, 1.0); - return mix(fog_color.xyz, rgb, factor); + // MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl); classic is bit-identical. + return fog_hook::apply_fog(rgb, fog_color.xyz, fog_span, eye_z, world_pos, view.world_position, + true, wow_light.monkey); } @vertex diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl index c5394a0b..d4345ae6 100644 --- a/crates/benilla-assets/src/shaders/terrain.wgsl +++ b/crates/benilla-assets/src/shaders/terrain.wgsl @@ -20,6 +20,8 @@ #import benilla::shadow_hook // MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table. #import benilla::monkey_frame +// MONKEY (p0 fog hook): the one distance-fog law every receiver calls. +#import benilla::fog_hook @group(#{MATERIAL_BIND_GROUP}) @binding(100) var layer_array: texture_2d_array; @group(#{MATERIAL_BIND_GROUP}) @binding(104) var alpha_array: texture_2d_array; @@ -780,9 +782,9 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 { // the per-pixel form is what survives Bevy's mesh interpolators without a custom slot. if (wow_light.fog_color.w > 0.5) { let eye_z = -(view.view_from_world * vec4(in.world_position.xyz, 1.0)).z; - let denom = max(wow_light.fog_params.y - wow_light.fog_params.x, 0.001); - let factor = clamp((wow_light.fog_params.y - eye_z) / denom, 0.0, 1.0); - tuned = mix(wow_light.fog_color.xyz, tuned, factor); + // MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl); classic is bit-identical. + tuned = fog_hook::apply_fog(tuned, wow_light.fog_color.xyz, wow_light.fog_params.xy, eye_z, + in.world_position.xyz, view.world_position, true, wow_light.monkey); } // Raw gamma out: the framebuffer holds gamma bytes and blends in gamma like the reference's; diff --git a/crates/benilla-assets/src/shaders/wdl.wgsl b/crates/benilla-assets/src/shaders/wdl.wgsl index ea85aa3e..d7a72666 100644 --- a/crates/benilla-assets/src/shaders/wdl.wgsl +++ b/crates/benilla-assets/src/shaders/wdl.wgsl @@ -19,6 +19,8 @@ // MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table. #import benilla::monkey_frame +// MONKEY (p0 fog hook): the one distance-fog law every receiver calls. +#import benilla::fog_hook /// How far the band reaches inside the far-clip wall: the reference's far-band near plane /// `farclip - 33.0` (`[0x8101b0]`, about one WDL outer cell). @@ -81,6 +83,10 @@ fn fragment(in: WdlVsOut) -> WdlFsOut { var rgb = vec3(1.0); if (w.fog_color.w > 0.5) { rgb = w.fog_color.xyz; + // MONKEY (p0 fog hook): the hull's own pair (start 0, end 1 yd) through the shared law, so + // a new fog model colours the horizon the way it colours the world; classic = the line above. + rgb = fog_hook::apply_fog(vec3(1.0), rgb, vec2(0.0, 1.0), eye_z, + in.world_position.xyz, view.world_position, true, w.monkey); } var out: WdlFsOut; diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index 0f211ca6..c7c9dcb4 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -22,6 +22,8 @@ #import benilla::shadow_hook // MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table. #import benilla::monkey_frame +// MONKEY (p0 fog hook): the one distance-fog law every receiver calls. +#import benilla::fog_hook // bevy_pbr 0.18.1's `forward_io::FragmentOutput`. No depth output: a fragment depth write costs // the pipeline early-Z, so the sky lane pins its depth in the vertex stage. @@ -1720,13 +1722,13 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut { let fog_policy = (u32(m.clutter_fade.z) >> 4u) & 7u; if (fog_color.w > 0.5 && fog_policy != 4u) { let eye_z = -(view.view_from_world * vec4(in.world_position.xyz, 1.0)).z; - let denom = max(fog_span.y - fog_span.x, 0.001); - let factor = clamp((fog_span.y - eye_z) / denom, 0.0, 1.0); var fog_rgb = fog_color.xyz; if (fog_policy == 1u) { fog_rgb = vec3(0.0); } else if (fog_policy == 2u) { fog_rgb = vec3(1.0); } else if (fog_policy == 3u) { fog_rgb = vec3(0.50196078); } - rgb = mix(fog_rgb, rgb, factor); + // MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl); classic is bit-identical. + rgb = fog_hook::apply_fog(rgb, fog_rgb, fog_span, eye_z, in.world_position.xyz, + view.world_position, fog_policy == 0u, wow_light.monkey); } diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl index 0850dc75..a626e709 100644 --- a/crates/benilla-world/src/shaders/static_gx.wgsl +++ b/crates/benilla-world/src/shaders/static_gx.wgsl @@ -12,6 +12,8 @@ #import benilla::shadow_hook // MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table. #import benilla::monkey_frame +// MONKEY (p0 fog hook): the one distance-fog law every receiver calls. +#import benilla::fog_hook // Group 0 is Bevy's standard mesh-view bind group (view matrices, directional-light records and // the shadow textures the retained pass reads). @@ -1980,9 +1982,9 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 { fog_span = wow_light.wmo_fog_params.xy; } if (fog_color.w > 0.5 && (in.word & WORD_FOG_OFF) == 0u) { - let denom = max(fog_span.y - fog_span.x, 0.001); - let factor = clamp((fog_span.y - eye_z) / denom, 0.0, 1.0); - rgb = mix(fog_color.xyz, rgb, factor); + // MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl); classic is bit-identical. + rgb = fog_hook::apply_fog(rgb, fog_color.xyz, fog_span, eye_z, in.world_position.xyz, + view.world_position, true, wow_light.monkey); } // Gamma-space output; alpha pinned 1.0, every draw here is opaque. return vec4(rgb, 1.0); diff --git a/crates/benilla-world/src/shaders/wow_effect.wgsl b/crates/benilla-world/src/shaders/wow_effect.wgsl index e3921b8f..ed16930b 100644 --- a/crates/benilla-world/src/shaders/wow_effect.wgsl +++ b/crates/benilla-world/src/shaders/wow_effect.wgsl @@ -14,6 +14,8 @@ #import bevy_render::view::View // MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table. #import benilla::monkey_frame +// MONKEY (p0 fog hook): the one distance-fog law every receiver calls. +#import benilla::fog_hook // Prefix of `lighting::global_light`'s buffer; keep in sync with wow_model.wgsl's copy. struct WowLight { @@ -73,6 +75,8 @@ struct VertexOutput { @location(1) color: vec4, // Planar eye-Z for fog and the farclip wall, positive in front of the camera. @location(2) view_z: f32, + // MONKEY (p0 fog hook): world position (Bevy space) for the fog hook's modern arm. + @location(3) world_pos: vec3, }; @vertex @@ -85,6 +89,7 @@ fn vertex(v: Vertex) -> VertexOutput { out.clip_position = view.clip_from_world * vec4(v.position, 1.0); // Eye-Z via the full affine transform; its rounding is yard-scale and harmless. out.view_z = -(view.view_from_world * vec4(v.position, 1.0)).z; + out.world_pos = v.position; // MONKEY (p0 fog hook) #else // Cam-relative verts: view_from_world is [R | −R·cam] and the rebase already subtracted cam, // so only the rotation applies. @@ -95,6 +100,7 @@ fn vertex(v: Vertex) -> VertexOutput { ) * v.position; out.clip_position = view.clip_from_view * vec4(view_pos, 1.0); out.view_z = -view_pos.z; + out.world_pos = v.position + view.world_position; // MONKEY (p0 fog hook): undo the rebase #endif out.uv = v.uv; out.color = v.color; @@ -150,19 +156,19 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { // Scene fog: the world's linear fog (same start and end, planar eye-Z) in gamma space before // the blend; only its colour follows the per-blend policy in `wow_params.fog.x`. if (wow_light.fog_color.w > 0.5 && wow_params.fog.x > 0.5) { - let denom = max(wow_light.fog_params.y - wow_light.fog_params.x, 0.001); - let factor = clamp((wow_light.fog_params.y - in.view_z) / denom, 0.0, 1.0); var fog_rgb = wow_light.fog_color.xyz; if (wow_params.fog.x > 1.5 && wow_params.fog.x < 2.5) { fog_rgb = vec3(0.0); } else if (wow_params.fog.x > 2.5 && wow_params.fog.x < 3.5) { fog_rgb = vec3(1.0); } else if (wow_params.fog.x > 3.5) { fog_rgb = RAIN_FOG_GREY; } - rgb = mix(fog_rgb, rgb, factor); + // MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl); classic is bit-identical. + rgb = fog_hook::apply_fog(rgb, fog_rgb, wow_light.fog_params.xy, in.view_z, in.world_pos, + view.world_position, wow_params.fog.x < 1.5, wow_light.monkey); } // Rain's forced fog: grey over the draw's own start/end, whatever the scene fog; grey is // neutral under Mod2x, so this is the streaks' distance fade. if (wow_params.fog.y > 0.5) { - let denom = max(wow_params.fog.w - wow_params.fog.z, 0.001); - let factor = clamp((wow_params.fog.w - in.view_z) / denom, 0.0, 1.0); + // MONKEY (p0 fog hook): the draw's own distance fade, always the classic linear law. + let factor = fog_hook::fog_linear(in.view_z, wow_params.fog.zw); rgb = mix(RAIN_FOG_GREY, rgb, factor); } #ifdef BLEND_ADD From 8c263244c9df8d365857eaec5d872e5d653ede1e Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 01:34:25 +0100 Subject: [PATCH 03/87] MONKEY (p0): skyDither cvar for the FFXGlow deband dither The combine's dither lane was armed only by the WOW_DITHER env var. It is now the `skyDither` cvar (0/1, default 0 = reference look; Graphics preset High = 1) with an Advanced Graphics "Sky Dithering" checkbox row (EN + RU). The cvar lands on `benilla_world::ffx_glow::SkyDither` (extracted) through the new `benilla-app/src/monkey_gfx.rs` bridge; WOW_DITHER=1 still forces it on. Co-Authored-By: Claude Opus 5.5 --- LIGHTING.md | 1 + crates/benilla-app/assets/ui/OptionsFrame.xml | 27 +++++++++++++++++ crates/benilla-app/src/cvars.rs | 12 ++++++++ crates/benilla-app/src/game_plugins.rs | 2 ++ crates/benilla-app/src/lib.rs | 2 ++ crates/benilla-app/src/monkey_gfx.rs | 30 +++++++++++++++++++ crates/benilla-world/src/ffx_glow.rs | 26 +++++++++++----- .../benilla-world/src/shaders/ffx_glow.wgsl | 2 +- 8 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 crates/benilla-app/src/monkey_gfx.rs diff --git a/LIGHTING.md b/LIGHTING.md index 27a804c4..0ffc9dea 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -24,6 +24,7 @@ behind each constant. | Moon shadows | at night the same shadow rig re-aims at the moon and casts a faint shadow; dims only the night sky term, never point lights | `moonShadowStrength` | | Ground-effect spells | Flamestrike, Rain of Fire, Consecration, Flare and fire traps light the ground for their duration; frost and nature areas stay dark | `spellLightGain` | | Volumetric fog | near-field haze that converges on the zone fog colour (clear within 10 yd, full by 150 yd; mistier at dawn and in bad weather, faint indoors) and sun/moon light shafts through gaps, sampled from the shadow map; own fullscreen pass after the main pass (`benilla-app/src/volumetric_fog.rs`) | Advanced Graphics → Volumetric Fog (Off/Low/High), cvar `volumetricFog`, env `WOW_VOLFOG=0\|1\|2` | +| Sky dithering | a faint screen-space dither in the FFXGlow combine so smooth sky and fog gradients do not band (MONKEY p0; was env-only) | Advanced Graphics → Sky Dithering, cvar `skyDither` 0/1 (default 0, Graphics preset High = 1), env `WOW_DITHER=1` still forces it on; bridge in `benilla-app/src/monkey_gfx.rs` | | Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` | Players reach all of it from **Options -> Advanced Graphics** (a Lighting Quality preset Off / Low / Medium / High plus the individual rows; Off is the original client look). The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default / diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 71e19e82..a54f95cb 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -519,6 +519,8 @@ spellLights = "Spell Lights", nightDarkness = "Night Darkness", interiorDarkness = "Interior Darkness", + -- MONKEY (p0 skyDither) + skyDither = "Sky Dithering", yards = "%d yd", tips = { LIGHTING_QUALITY = @@ -611,6 +613,10 @@ "Scales interior fixture light and room ambient, including the light from " .. "lamps and candles. Lower values make both dimmer.\n\n" .. "1.0 leaves these inputs unchanged. Free at any setting.", + -- MONKEY (p0 skyDither) + SKY_DITHER = + "Adds a faint noise to the final image so smooth sky and fog gradients do not " + .. "break into visible bands. Off is the original client's look. Practically free.", }, }, ruRU = { @@ -643,6 +649,8 @@ spellLights = "Свет от заклинаний", nightDarkness = "Темнота ночи", interiorDarkness = "Темнота помещений", + -- MONKEY (p0 skyDither) + skyDither = "Дизеринг неба", yards = "%d ярд.", tips = { LIGHTING_QUALITY = @@ -743,6 +751,11 @@ "Масштабирует свет светильников и фоновое освещение помещений, включая " .. "свет ламп и свечей. Меньшие значения ослабляют и то и другое.\n\n" .. "1.0 оставляет эти составляющие без изменений. Ничего не стоит при любом значении.", + -- MONKEY (p0 skyDither) + SKY_DITHER = + "Добавляет едва заметный шум в итоговое изображение, чтобы плавные переходы цвета " + .. "неба и тумана не распадались на полосы. Выключено — вид оригинального клиента. " + .. "Почти ничего не стоит.", }, }, } @@ -801,6 +814,8 @@ "RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft", "RowFireLight", "RowFireFlicker", "RowSpellLights", "RowLavaGlow", "RowNightDarkness", "RowInteriorDarkness", + -- MONKEY (p0 skyDither) + "RowSkyDither", }, -- Player Guild Names is a child of Player Names, as in 1.12 (l.702-708). Nameplates = { @@ -2827,6 +2842,18 @@ OptionsSliderRow_Setup(self, 0.2, 1.5, 0.05, "darkness") + + + + + + + + OptionsRow_OnLoad(self, "skyDither", + BENILLA_ADVGFX.skyDither, + "BENILLA_TOOLTIP_SKY_DITHER") + + + + + OptionsRow_OnLoad(self, "bloom", BENILLA_ADVGFX.bloom, + "BENILLA_TOOLTIP_BLOOM") + OptionsDropdownRow_Setup(self, { + { text = BENILLA_ADVGFX.fogOff, value = "0" }, + { text = BENILLA_ADVGFX.qLow, value = "1" }, + { text = BENILLA_ADVGFX.qHigh, value = "2" }, + }) + + + + + + + + OptionsRow_OnLoad(self, "characterShadows", BENILLA_ADVGFX.characterShadows, diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 31a7eced..66156e99 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -921,6 +921,8 @@ pub(crate) const REGISTERED: &[Registered] = &[ ), // MONKEY (volumetric fog): saved live tier; capture override stays session-only. ours("volumetricFog", "1", "benilla's own: near-field volumetric fog, 0 Off / 1 Low / 2 High"), + // MONKEY (post): tier 0 leaves every emissive site and the frame byte-identical. + ours("bloom", "0", "benilla's own: HDR emissive bloom, 0 Off / 1 Low / 2 High"), ours( "waterQuality", "1", @@ -2902,6 +2904,8 @@ mod tests { ("waterQuality", shadows.water_quality as f32), // MONKEY (volumetric fog): weld registry and renderer defaults. ("volumetricFog", shadows.volumetric_fog as f32), + // MONKEY (post): weld registry and renderer defaults. + ("bloom", shadows.bloom as f32), ("lavaLightGain", shadows.lava_light_gain), // The two sun lanes and the cascade they share. ("worldShadows", flag(shadows.world_shadows)), @@ -3133,6 +3137,8 @@ mod tests { "waterQuality" => video.water_quality as f32, // MONKEY (volumetric fog): verify presets reach the renderer. "volumetricFog" => video.volumetric_fog as f32, + // MONKEY (post): the post lane's live tier. + "bloom" => video.bloom as f32, "lavaLightGain" => video.lava_light_gain, "fireLightGain" => video.fire_light_gain, "moonShadowStrength" => video.moon_shadow_strength, diff --git a/crates/benilla-app/src/dynamic_interior.rs b/crates/benilla-app/src/dynamic_interior.rs index e34976f6..1fbb6fa8 100644 --- a/crates/benilla-app/src/dynamic_interior.rs +++ b/crates/benilla-app/src/dynamic_interior.rs @@ -25,7 +25,9 @@ //! //! Water quality and lava glow use the same guarded bridge into their renderer resources. use benilla_assets::WaterQuality; -use benilla_world::lighting::{DynamicInteriors, FireLightGain, LavaLightGain, MoonShadowStrength, SpellLightGain}; +use benilla_world::lighting::{ + DynamicInteriors, EmissiveTier, FireLightGain, LavaLightGain, MoonShadowStrength, SpellLightGain, +}; use bevy::prelude::*; use crate::video::VideoConfig; @@ -60,6 +62,8 @@ fn bridge( mut moon: ResMut, mut water: ResMut, mut lava: ResMut, + // MONKEY (post): bloom also arms the HDR source hooks through the shared light blob. + mut emissive: ResMut, ) { if water.0 != video.water_quality { water.0 = video.water_quality; @@ -67,6 +71,9 @@ fn bridge( if lava.0 != video.lava_light_gain { lava.0 = video.lava_light_gain; } + if emissive.0 != video.bloom { + emissive.0 = video.bloom; + } if fire.0 != video.fire_light_gain { fire.0 = video.fire_light_gain; } @@ -130,6 +137,7 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() + .init_resource::() .add_plugins(DynamicInteriorPlugin); assert_eq!(app.world().resource::().0, VideoConfig::default().water_quality); assert_eq!(app.world().resource::().0, VideoConfig::default().lava_light_gain); diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index 07c17824..30a2b12f 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -222,6 +222,8 @@ pub(crate) struct VideoConfig { pub(crate) water_quality: u8, // MONKEY (volumetric fog): live camera raymarch tier: 0 Off, 1 Low, 2 High. pub(crate) volumetric_fog: u8, + // MONKEY (post): emissive HDR + bloom quality, 0 Off / 1 Low / 2 High. + pub(crate) bloom: u8, /// Brightness of lava lighting its surroundings, 0..4; 0 disables the glow. /// Published to `benilla_world::lighting::LavaLightGain` by `dynamic_interior::bridge`. pub(crate) lava_light_gain: f32, @@ -467,6 +469,8 @@ impl Default for VideoConfig { water_quality: 1, // MONKEY (volumetric fog): default to the inexpensive atmosphere. volumetric_fog: 1, + // MONKEY (post): opt-in; the future High graphics preset selects tier 2. + bloom: 0, lava_light_gain: 1.0, fire_flicker: 1.0, display: if windowed_env() { @@ -547,6 +551,8 @@ pub(crate) fn on_cvar( "waterquality" => cfg.water_quality = v.clamp(0.0, 2.0) as u8, // MONKEY (volumetric fog): constrain UI/console writes to supported tiers. "volumetricfog" => cfg.volumetric_fog = v.clamp(0.0, 2.0) as u8, + // MONKEY (post): constrain UI/console writes to the supported bloom tiers. + "bloom" => cfg.bloom = v.clamp(0.0, 2.0) as u8, "lavalightgain" => cfg.lava_light_gain = v.clamp(0.0, 4.0), "worldshadows" => cfg.world_shadows = ev.flag(), "charactershadows" => cfg.character_shadows = ev.flag(), diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs index 18aa1682..fec812b8 100644 --- a/crates/benilla-assets/src/materials.rs +++ b/crates/benilla-assets/src/materials.rs @@ -29,6 +29,8 @@ pub fn register_shaders(app: &mut App) { // (loaded eagerly so `#import benilla::shadow_hook` resolves in terrain/model/static_gx). This is // the ONE place the realtime directional-shadow term lives; the receivers just call into it. load_shader_library!(app, "shaders/shadow_hook.wgsl"); + // MONKEY (post): shared opt-in HDR multipliers; tier 0 is an exact identity. + load_shader_library!(app, "shaders/emissive_hook.wgsl"); // MONKEY (enhanced water): the optional water module, `benilla::enhanced_water` (WATER.md). load_shader_library!(app, "shaders/enhanced_water.wgsl"); } diff --git a/crates/benilla-assets/src/shaders/emissive_hook.wgsl b/crates/benilla-assets/src/shaders/emissive_hook.wgsl new file mode 100644 index 00000000..8668b08d --- /dev/null +++ b/crates/benilla-assets/src/shaders/emissive_hook.wgsl @@ -0,0 +1,36 @@ +#define_import_path benilla::emissive_hook + +// MONKEY (post): the opt-in HDR seam shared by windows, additive models/effects and magma. +// `light_diffuse.w` was the reference pack's unused clamp marker (always 1); the CPU now adds +// the `bloom` tier, so 1 remains a byte-identical Off sentinel without growing the light blob. +const EMISSIVE_WMO_WINDOW: u32 = 0u; +const EMISSIVE_M2_ADD: u32 = 1u; +const EMISSIVE_PARTICLE_ADD: u32 = 2u; +const EMISSIVE_MAGMA: u32 = 3u; + +fn tier(packed_lane: f32) -> f32 { + return clamp(round(packed_lane - 1.0), 0.0, 2.0); +} + +fn emissive_boost( + color: vec3, + kind: u32, + packed_lane: f32, + authored_weight: f32, +) -> vec3 { + let t = tier(packed_lane); + if (t < 0.5) { + return color; + } + let high = step(1.5, t); + var gain = mix(1.45, 1.85, high); + if (kind == EMISSIVE_WMO_WINDOW) { gain = mix(1.70, 2.35, high); } + if (kind == EMISSIVE_MAGMA) { gain = mix(1.80, 2.60, high); } + return color * mix(1.0, gain, clamp(authored_weight, 0.0, 1.0)); +} + +// Distant magma keeps part of its emissive body instead of converging completely to fog. +fn magma_fog_resist(packed_lane: f32) -> f32 { + let t = tier(packed_lane); + return select(0.0, mix(0.32, 0.48, step(1.5, t)), t > 0.5); +} diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl index 1a9d72e7..36eb0d35 100644 --- a/crates/benilla-assets/src/shaders/liquid.wgsl +++ b/crates/benilla-assets/src/shaders/liquid.wgsl @@ -30,6 +30,8 @@ } // MONKEY (enhanced water): the optional water module - see enhanced_water.wgsl and WATER.md. #import benilla::enhanced_water::{water_active, water_swell, enhanced_water, WaterFragment} +// MONKEY (post): shared tier-gated HDR emission and magma fog resistance. +#import benilla::emissive_hook @group(#{MATERIAL_BIND_GROUP}) @binding(100) var frames: texture_2d_array; @group(#{MATERIAL_BIND_GROUP}) @binding(101) var frames_samp: sampler; @@ -227,7 +229,12 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { // Magma/slime: the sheet is the opaque body, unmodulated (the ADT vertex has no colour, the WMO // one is `0xffffffff`) and unlit (lighting off on both paths), but fogged. if (w.kind.x > 0.5) { - return vec4(apply_fog(detail.rgb, in.world_position.xyz, in.room_fog), 1.0); + // MONKEY (post): distant magma keeps some authored body and crosses 1.0 for bloom. + let fogged = apply_fog(detail.rgb, in.world_position.xyz, in.room_fog); + let hot = emissive_hook::emissive_boost( + detail.rgb, emissive_hook::EMISSIVE_MAGMA, wow_light.light_diffuse.w, 1.0); + return vec4(mix(fogged, hot, + emissive_hook::magma_fog_resist(wow_light.light_diffuse.w)), 1.0); } // V, from the authored depth byte CPU-side: clamp(byte/42) on river/lake (LUT `0xc81768`, diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index 07705ba5..363d06e9 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -20,6 +20,8 @@ } // MONKEY (shadow hook): the realtime directional-shadow term (fetch + edge/night fade) lives here. #import benilla::shadow_hook +// MONKEY (post): shared tier-gated HDR emission; Off is an exact identity. +#import benilla::emissive_hook // bevy_pbr 0.18.1's `forward_io::FragmentOutput`. No depth output: a fragment depth write costs // the pipeline early-Z, so the sky lane pins its depth in the vertex stage. @@ -1737,6 +1739,9 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut { var out_rgb = rgb; if (is_additive) { out_rgb = out_rgb * faded_alpha; + // MONKEY (post): additive M2 cards become HDR before their framebuffer blend stacks them. + out_rgb = emissive_hook::emissive_boost( + out_rgb, emissive_hook::EMISSIVE_M2_ADD, wow_light.light_diffuse.w, 1.0); } // Mod (bit 7) and Mod2x (bit 8) read no source alpha, so the fade rides the colour as in the // reference: texenv preset 5, `mix(prev.rgb, tex.rgb, prev.a)`, with the primary colour forced diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs index b3373de0..4e63fa07 100644 --- a/crates/benilla-world/src/lighting/global_light.rs +++ b/crates/benilla-world/src/lighting/global_light.rs @@ -474,6 +474,11 @@ impl Default for FireLightGain { } } +/// MONKEY (post): live `bloom` tier. The packer adds it to `light_diffuse.w`, whose old value 1 +/// was an unread clamp marker, so the 8528-byte shared buffer and every existing decode stay put. +#[derive(Resource, Clone, Copy, Default, PartialEq, Eq, Debug)] +pub struct EmissiveTier(pub u8); + /// MONKEY (spellLightGain): marks a `PointLight` that a SPELL EFFECT invented — a kit's aura glow, /// a missile's core, an impact flash, a firework shell's burst. The app's own /// `entities::spell_fx::SpellLight` carries the envelope, the mode and the budget; this is the @@ -641,6 +646,8 @@ pub(super) fn register(app: &mut App) { .init_resource::() // MONKEY (fire GO lights): the live gain on synthesised fire lights. .init_resource::() + // MONKEY (post): opt-in HDR emission, packed into an existing float lane. + .init_resource::() // MONKEY (spellLightGain): and the one on spell-effect lights, which overrides it. .init_resource::() .init_resource::() @@ -1286,6 +1293,8 @@ fn build_light_data( shadow: ShadowLanes, // MONKEY (dynamic interiors): the interior lane's on/off + live knobs, packed for `static_gx.wgsl`. dynamic_interiors: Res, + // MONKEY (post): the 0/1/2 tier rides the unused `light_diffuse.w` marker. + emissive: Res, // MONKEY (fire GO lights): the live gain on synthesised fire lights (0 = the lane off). fire_gain: Res, // MONKEY (spellLightGain): and the spell lane's own, which overrides it on a spell row. @@ -1353,6 +1362,8 @@ fn build_light_data( // this packs (rows 0/1, the SH block, the sun's DC redistribution) is LINEAR in that triple, so // one multiply at the input dims all of them consistently and no derived row can be missed. pack_model_core_rows(rows, night_dim(l.ambient), night_dim(l.diffuse), l.sun_dir); + // MONKEY (post): old value 1 remains tier Off; no existing shader consumes this marker. + rows[1][3] = 1.0 + emissive.0.min(2) as f32; // MONKEY (world shadows): pack the world-shadow lane flag into the free `sh_c16.w` lane. The // MCSH terrain-shadow suppression in `terrain.wgsl` keys on THIS — not on the mere presence of // a shadow sun — so character-only shadows (sun present, world lane off) keep the baked MCSH. @@ -2037,6 +2048,8 @@ mod tests { .init_resource::() .init_resource::() .init_resource::() + // MONKEY (post): the packer reads the live HDR tier too. + .init_resource::() .init_resource::() .add_systems(Update, build_light_data); app.world_mut() diff --git a/crates/benilla-world/src/lighting/mod.rs b/crates/benilla-world/src/lighting/mod.rs index 027a4ce0..7f745974 100644 --- a/crates/benilla-world/src/lighting/mod.rs +++ b/crates/benilla-world/src/lighting/mod.rs @@ -51,6 +51,8 @@ pub use global_light::WorldPointLight; // MONKEY (spellLightGain): the spell lane's marker + its live gain — the two-word world-side // shadow of benilla-app's own `SpellLight` lifecycle, and the dial the packer folds over it. pub use global_light::{SpellFxLight, SpellLightGain}; +// MONKEY (post): live 0/1/2 emissive tier packed without growing the shared light blob. +pub use global_light::EmissiveTier; pub use global_light::{ room_claim_bytes, CLAIM_EXT_OK, LIT_ROOM_EXT_DENY, ROOM_CLAIM_MAX, ROOM_CLAIM_STRIDE, }; diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl index ebe78d5a..41e12b7c 100644 --- a/crates/benilla-world/src/shaders/static_gx.wgsl +++ b/crates/benilla-world/src/shaders/static_gx.wgsl @@ -10,6 +10,8 @@ } // MONKEY (shadow hook): the realtime directional-shadow term (fetch + edge/night fade) lives here. #import benilla::shadow_hook +// MONKEY (post): shared tier-gated HDR emission; Off is an exact identity. +#import benilla::emissive_hook // Group 0 is Bevy's standard mesh-view bind group (view matrices, directional-light records and // the shadow textures the retained pass reads). @@ -1980,6 +1982,12 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 { let factor = clamp((fog_span.y - eye_z) / denom, 0.0, 1.0); rgb = mix(fog_color.xyz, rgb, factor); } + // MONKEY (post): SIDN/window batches cross 1.0 only at night and when bloom is armed. + if ((in.word & WORD_WINDOW) != 0u) { + rgb = emissive_hook::emissive_boost( + rgb, emissive_hook::EMISSIVE_WMO_WINDOW, wow_light.light_diffuse.w, + wow_light.grade.x); + } // Gamma-space output; alpha pinned 1.0, every draw here is opaque. return vec4(rgb, 1.0); } diff --git a/crates/benilla-world/src/shaders/wow_effect.wgsl b/crates/benilla-world/src/shaders/wow_effect.wgsl index f6654dd9..abf918a9 100644 --- a/crates/benilla-world/src/shaders/wow_effect.wgsl +++ b/crates/benilla-world/src/shaders/wow_effect.wgsl @@ -12,6 +12,8 @@ // - BLEND_MOD2X: (rgb, 1) under (Dst, Src) = `2·src·dst`, rain's state; reads no alpha. #import bevy_render::view::View +// MONKEY (post): shared tier-gated HDR emission; Off is an exact identity. +#import benilla::emissive_hook // Prefix of `lighting::global_light`'s buffer; keep in sync with wow_model.wgsl's copy. struct WowLight { @@ -160,7 +162,9 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { } #ifdef BLEND_ADD // Premultiplied in gamma, so stacked quads sum like the reference's bytes. - return vec4(rgb * c.a, 0.0); + // MONKEY (post): boost before the additive framebuffer blend, never after the stack. + return vec4(emissive_hook::emissive_boost( + rgb * c.a, emissive_hook::EMISSIVE_PARTICLE_ADD, wow_light.light_diffuse.w, 1.0), 0.0); #else #ifdef BLEND_OPAQUE return vec4(rgb, 1.0); From 5c1cc7ab079b80c2cd0e79e246ad0ca19121daf9 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 02:20:38 +0100 Subject: [PATCH 06/87] MONKEY (post): add world-only HDR bloom pass Co-Authored-By: GPT-5.6 Sol (Codex) --- crates/benilla-app/src/cvars.rs | 10 +- crates/benilla-app/src/game_plugins.rs | 2 + crates/benilla-app/src/lib.rs | 4 +- crates/benilla-app/src/post/bloom.rs | 366 +++++++++++++++++++++++++ crates/benilla-app/src/post/bloom.wgsl | 68 +++++ crates/benilla-app/src/post/mod.rs | 11 + crates/benilla-app/src/video.rs | 4 +- crates/benilla-world/src/ffx_glow.rs | 3 +- 8 files changed, 463 insertions(+), 5 deletions(-) create mode 100644 crates/benilla-app/src/post/bloom.rs create mode 100644 crates/benilla-app/src/post/bloom.wgsl create mode 100644 crates/benilla-app/src/post/mod.rs diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 66156e99..ff3c06ce 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -922,7 +922,7 @@ pub(crate) const REGISTERED: &[Registered] = &[ // MONKEY (volumetric fog): saved live tier; capture override stays session-only. ours("volumetricFog", "1", "benilla's own: near-field volumetric fog, 0 Off / 1 Low / 2 High"), // MONKEY (post): tier 0 leaves every emissive site and the frame byte-identical. - ours("bloom", "0", "benilla's own: HDR emissive bloom, 0 Off / 1 Low / 2 High"), + ours("bloom", "2", "benilla's own: HDR emissive bloom, 0 Off / 1 Low / 2 High"), ours( "waterQuality", "1", @@ -2010,6 +2010,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("waterQuality", "0"), // MONKEY (volumetric fog): Off preset restores the original atmosphere. ("volumetricFog", "0"), + // MONKEY (post): no HDR lift or post pass is the byte-identical baseline. + ("bloom", "0"), ("lavaLightGain", "0"), ("fireLightGain", "0"), ("nightGain", "1.0"), @@ -2033,6 +2035,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("waterQuality", "1"), // MONKEY (volumetric fog): preset atmosphere uses the default cheap tier. ("volumetricFog", "1"), + // MONKEY (post): quarter-resolution halo. + ("bloom", "1"), ("lavaLightGain", "1"), ("fireLightGain", "1"), ("nightGain", "0.45"), @@ -2059,6 +2063,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("waterQuality", "1"), // MONKEY (volumetric fog): preset atmosphere uses the default cheap tier. ("volumetricFog", "1"), + // MONKEY (post): quarter-resolution halo. + ("bloom", "1"), ("lavaLightGain", "1"), ("fireLightGain", "1"), ("nightGain", "0.45"), @@ -2086,6 +2092,8 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("waterQuality", "1"), // MONKEY (volumetric fog): preset atmosphere uses the default cheap tier. ("volumetricFog", "1"), + // MONKEY (post): half-resolution halo. + ("bloom", "2"), ("lavaLightGain", "1"), ("fireLightGain", "1"), ("nightGain", "0.45"), diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index adda66ec..34ae7853 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -245,6 +245,8 @@ impl PluginGroup for GamePlugins { .add(crate::dynamic_interior::DynamicInteriorPlugin) // MONKEY (volumetric fog): bridge the live setting to Bevy. .add(crate::volumetric_fog::VolumetricFogPlugin) + // MONKEY (post): world-only HDR effects, before the legacy FFX clamp/UI composite. + .add(crate::post::PostPlugin) // The realmlist (decision 1667) — the logon address the login screen edits. Same reason as // VideoPlugin above: it is a CVar knob, so its resource has to exist before `load_config`. .add(crate::realmlist::RealmlistPlugin) diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index a7f9ac23..61e277a9 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -72,9 +72,11 @@ mod debug_panel; mod dev; mod doodad_events; /// MONKEY (dynamic interiors): the cvar → benilla-world bridge for the fixture-lit interior lane. +mod dynamic_interior; // MONKEY (volumetric fog): optional near-field atmosphere plugin. mod volumetric_fog; -mod dynamic_interior; +// MONKEY (post): optional world-only HDR post-processing. +mod post; mod entities; mod fishing_line; mod footprints; diff --git a/crates/benilla-app/src/post/bloom.rs b/crates/benilla-app/src/post/bloom.rs new file mode 100644 index 00000000..336388ea --- /dev/null +++ b/crates/benilla-app/src/post/bloom.rs @@ -0,0 +1,366 @@ +//! MONKEY (post): HDR-excess bloom. This runs on the world view before FFXGlow clamps it; +//! the later UI camera therefore never enters either the extraction or halo combine. + +use crate::video::VideoConfig; +use benilla_world::{ffx_glow::FfxGlowLabel, view::WorldCamera}; +use bevy::{ + core_pipeline::{ + core_3d::graph::{Core3d, Node3d}, + FullscreenShader, + }, + ecs::query::QueryItem, + prelude::*, + render::{ + camera::ExtractedCamera, + diagnostic::RecordDiagnostics, + extract_component::{ExtractComponent, ExtractComponentPlugin}, + render_graph::{ + NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner, + }, + render_resource::{binding_types::*, *}, + renderer::{RenderContext, RenderDevice, RenderQueue}, + texture::{CachedTexture, TextureCache}, + view::ViewTarget, + Render, RenderApp, RenderStartup, RenderSystems, + }, +}; + +pub(super) struct BloomPlugin; + +#[derive(Component, Clone, Copy, ExtractComponent)] +struct BloomView { + tier: u32, +} + +impl Plugin for BloomPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(ExtractComponentPlugin::::default()) + .add_systems(Last, update_views); + if !app.is_plugin_added::() { + return; + } + let shader = app + .world_mut() + .resource_mut::>() + .add(Shader::from_wgsl( + include_str!("bloom.wgsl"), + "post/bloom.wgsl", + )); + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + render_app + .insert_resource(BloomShader(shader)) + .add_systems(RenderStartup, init_pipeline) + .add_systems( + Render, + prepare_textures.in_set(RenderSystems::PrepareResources), + ) + .add_render_graph_node::>(Core3d, BloomLabel) + .add_render_graph_edges( + Core3d, + ( + Node3d::StartMainPassPostProcessing, + BloomLabel, + FfxGlowLabel, + ), + ); + } +} + +fn update_views( + mut commands: Commands, + video: Res, + cameras: Query<(Entity, &Camera, Option<&BloomView>), With>, +) { + let tier = u32::from(video.bloom.min(2)); + for (entity, camera, old) in &cameras { + if tier == 0 || !camera.is_active { + if old.is_some() { + commands.entity(entity).remove::(); + } + } else if old.is_none_or(|old| old.tier != tier) { + commands.entity(entity).insert(BloomView { tier }); + } + } +} + +#[derive(Resource)] +struct BloomShader(Handle); + +#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)] +pub(super) struct BloomLabel; + +#[derive(Resource)] +struct BloomPipelines { + layout: BindGroupLayoutDescriptor, + sampler: Sampler, + extract: CachedRenderPipelineId, + blur_h: CachedRenderPipelineId, + blur_v: CachedRenderPipelineId, + combine: CachedRenderPipelineId, +} + +fn init_pipeline( + mut commands: Commands, + render_device: Res, + fullscreen: Res, + shader: Res, + pipeline_cache: Res, +) { + let layout = BindGroupLayoutDescriptor::new( + "post_bloom_layout", + &BindGroupLayoutEntries::sequential( + ShaderStages::FRAGMENT, + ( + texture_2d(TextureSampleType::Float { filterable: true }), + sampler(SamplerBindingType::Filtering), + texture_2d(TextureSampleType::Float { filterable: true }), + uniform_buffer_sized(false, std::num::NonZeroU64::new(16)), + ), + ), + ); + let sampler = render_device.create_sampler(&SamplerDescriptor { + min_filter: FilterMode::Linear, + mag_filter: FilterMode::Linear, + address_mode_u: AddressMode::ClampToEdge, + address_mode_v: AddressMode::ClampToEdge, + ..default() + }); + let pipeline = |label: &'static str, entry: &'static str| RenderPipelineDescriptor { + label: Some(label.into()), + layout: vec![layout.clone()], + vertex: fullscreen.to_vertex_state(), + fragment: Some(FragmentState { + shader: shader.0.clone(), + shader_defs: vec![], + entry_point: Some(entry.into()), + targets: vec![Some(ColorTargetState { + format: ViewTarget::TEXTURE_FORMAT_HDR, + blend: None, + write_mask: ColorWrites::ALL, + })], + }), + ..default() + }; + let extract = + pipeline_cache.queue_render_pipeline(pipeline("post_bloom_extract", "fs_extract")); + let blur_h = pipeline_cache.queue_render_pipeline(pipeline("post_bloom_blur_h", "fs_blur_h")); + let blur_v = pipeline_cache.queue_render_pipeline(pipeline("post_bloom_blur_v", "fs_blur_v")); + let combine = + pipeline_cache.queue_render_pipeline(pipeline("post_bloom_combine", "fs_combine")); + commands.insert_resource(BloomPipelines { + layout, + sampler, + extract, + blur_h, + blur_v, + combine, + }); +} + +#[derive(Component)] +struct BloomTextures { + a: CachedTexture, + b: CachedTexture, + uniform: Buffer, + bind_ab: BindGroup, + bind_ba: BindGroup, + tier: u32, +} + +fn prepare_textures( + mut commands: Commands, + mut cache: ResMut, + render_device: Res, + pipeline_cache: Res, + pipelines: Res, + views: Query<(Entity, &ExtractedCamera, &BloomView, Option<&BloomTextures>)>, +) { + let layout = pipeline_cache.get_bind_group_layout(&pipelines.layout); + for (entity, camera, view, old) in &views { + let Some(size) = camera.physical_viewport_size else { + continue; + }; + let divisor = if view.tier == 1 { 4 } else { 2 }; + let mut texture = |label: &'static str, width: u32, height: u32| { + cache.get( + &render_device, + TextureDescriptor { + label: Some(label), + size: Extent3d { + width: width.max(8), + height: height.max(8), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: TextureDimension::D2, + format: ViewTarget::TEXTURE_FORMAT_HDR, + usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }, + ) + }; + let a = texture("post_bloom_a", size.x / divisor, size.y / divisor); + let b = texture("post_bloom_b", size.x / divisor, size.y / divisor); + if old.is_some_and(|old| { + old.tier == view.tier + && old.a.texture.id() == a.texture.id() + && old.b.texture.id() == b.texture.id() + }) { + continue; + } + let uniform = render_device.create_buffer(&BufferDescriptor { + label: Some("post_bloom_uniform"), + size: 16, + usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let bind = |label, source: &TextureView, auxiliary: &TextureView| { + render_device.create_bind_group( + label, + &layout, + &BindGroupEntries::sequential(( + source, + &pipelines.sampler, + auxiliary, + uniform.as_entire_binding(), + )), + ) + }; + let bind_ab = bind("post_bloom_a_to_b", &a.default_view, &a.default_view); + let bind_ba = bind("post_bloom_b_to_a", &b.default_view, &b.default_view); + commands.entity(entity).insert(BloomTextures { + a, + b, + uniform, + bind_ab, + bind_ba, + tier: view.tier, + }); + } +} + +#[derive(Default)] +struct BloomNode; + +impl ViewNode for BloomNode { + type ViewQuery = ( + &'static ViewTarget, + &'static BloomView, + &'static BloomTextures, + ); + + fn run<'w>( + &self, + _graph: &mut RenderGraphContext, + render_context: &mut RenderContext<'w>, + (target, view, textures): QueryItem<'w, '_, Self::ViewQuery>, + world: &'w World, + ) -> Result<(), NodeRunError> { + let pipelines = world.resource::(); + let cache = world.resource::(); + let (Some(extract), Some(blur_h), Some(blur_v), Some(combine)) = ( + cache.get_render_pipeline(pipelines.extract), + cache.get_render_pipeline(pipelines.blur_h), + cache.get_render_pipeline(pipelines.blur_v), + cache.get_render_pipeline(pipelines.combine), + ) else { + return Ok(()); + }; + let gain = if view.tier == 1 { 0.55 } else { 0.78 }; + let params = [view.tier as f32, gain, 0.0, 0.0]; + world.resource::().write_buffer( + &textures.uniform, + 0, + bytemuck::cast_slice(¶ms), + ); + let device = render_context.render_device().clone(); + let layout = cache.get_bind_group_layout(&pipelines.layout); + let source = target.main_texture_view(); + let extract_bind = device.create_bind_group( + "post_bloom_extract", + &layout, + &BindGroupEntries::sequential(( + source, + &pipelines.sampler, + &textures.b.default_view, + textures.uniform.as_entire_binding(), + )), + ); + let diagnostics = render_context.diagnostic_recorder(); + for (label, pipeline, bind, destination) in [ + ( + "post_bloom_extract", + extract, + &extract_bind, + &textures.a.default_view, + ), + ( + "post_bloom_blur_h", + blur_h, + &textures.bind_ab, + &textures.b.default_view, + ), + ( + "post_bloom_blur_v", + blur_v, + &textures.bind_ba, + &textures.a.default_view, + ), + ] { + let mut pass = + render_context + .command_encoder() + .begin_render_pass(&RenderPassDescriptor { + label: Some(label), + color_attachments: &[Some(RenderPassColorAttachment { + view: destination, + depth_slice: None, + resolve_target: None, + ops: Operations::default(), + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + let span = diagnostics.pass_span(&mut pass, label); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, bind, &[]); + pass.draw(0..3, 0..1); + span.end(&mut pass); + } + let output = target.post_process_write(); + let combine_bind = device.create_bind_group( + "post_bloom_combine", + &layout, + &BindGroupEntries::sequential(( + output.source, + &pipelines.sampler, + &textures.a.default_view, + textures.uniform.as_entire_binding(), + )), + ); + let mut pass = render_context + .command_encoder() + .begin_render_pass(&RenderPassDescriptor { + label: Some("post_bloom_combine"), + color_attachments: &[Some(RenderPassColorAttachment { + view: output.destination, + depth_slice: None, + resolve_target: None, + ops: Operations::default(), + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + let span = diagnostics.pass_span(&mut pass, "post_bloom_combine"); + pass.set_pipeline(combine); + pass.set_bind_group(0, &combine_bind, &[]); + pass.draw(0..3, 0..1); + span.end(&mut pass); + Ok(()) + } +} diff --git a/crates/benilla-app/src/post/bloom.wgsl b/crates/benilla-app/src/post/bloom.wgsl new file mode 100644 index 00000000..7313a1d4 --- /dev/null +++ b/crates/benilla-app/src/post/bloom.wgsl @@ -0,0 +1,68 @@ +// MONKEY (post): world-only HDR excess extraction, dual-filter blur, and halo composite. + +#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput + +@group(0) @binding(0) var source_tex: texture_2d; +@group(0) @binding(1) var linear_sampler: sampler; +@group(0) @binding(2) var auxiliary_tex: texture_2d; + +struct BloomParams { + tier: f32, + gain: f32, + unused: vec2, +}; +@group(0) @binding(3) var bloom: BloomParams; + +fn tap(tex: texture_2d, uv: vec2, offset: vec2) -> vec4 { + let size = vec2(textureDimensions(tex)); + return textureSampleLevel(tex, linear_sampler, uv + offset / size, 0.0); +} + +@fragment +fn fs_extract(in: FullscreenVertexOutput) -> @location(0) vec4 { + // 13-tap downfilter: a broad, stable footprint when Low uses a quarter-size target. + var c = tap(source_tex, in.uv, vec2(0.0)) * 0.20; + c += tap(source_tex, in.uv, vec2( 1.0, 0.0)) * 0.10; + c += tap(source_tex, in.uv, vec2(-1.0, 0.0)) * 0.10; + c += tap(source_tex, in.uv, vec2( 0.0, 1.0)) * 0.10; + c += tap(source_tex, in.uv, vec2( 0.0, -1.0)) * 0.10; + c += tap(source_tex, in.uv, vec2( 1.0, 1.0)) * 0.06; + c += tap(source_tex, in.uv, vec2(-1.0, 1.0)) * 0.06; + c += tap(source_tex, in.uv, vec2( 1.0, -1.0)) * 0.06; + c += tap(source_tex, in.uv, vec2(-1.0, -1.0)) * 0.06; + c += tap(source_tex, in.uv, vec2( 2.0, 0.0)) * 0.04; + c += tap(source_tex, in.uv, vec2(-2.0, 0.0)) * 0.04; + c += tap(source_tex, in.uv, vec2( 0.0, 2.0)) * 0.04; + c += tap(source_tex, in.uv, vec2( 0.0, -2.0)) * 0.04; + let excess = max(c.rgb - vec3(1.0), vec3(0.0)); + let knee = excess * excess / (excess + vec3(0.50)); + // Frost-Nova/firework stacks can reach 5-10x; compress them without hard clipping. + let capped = vec3(5.0) * (vec3(1.0) - exp(-knee / vec3(5.0))); + return vec4(capped, 1.0); +} + +fn blur_axis(uv: vec2, axis: vec2) -> vec4 { + var c = tap(source_tex, uv, vec2(0.0)) * 0.227027; + c += tap(source_tex, uv, axis * 1.384615) * 0.316216; + c += tap(source_tex, uv, axis * -1.384615) * 0.316216; + c += tap(source_tex, uv, axis * 3.230769) * 0.070270; + c += tap(source_tex, uv, axis * -3.230769) * 0.070270; + return c; +} + +@fragment +fn fs_blur_h(in: FullscreenVertexOutput) -> @location(0) vec4 { + return blur_axis(in.uv, vec2(1.0, 0.0)); +} + +@fragment +fn fs_blur_v(in: FullscreenVertexOutput) -> @location(0) vec4 { + return blur_axis(in.uv, vec2(0.0, 1.0)); +} + +@fragment +fn fs_combine(in: FullscreenVertexOutput) -> @location(0) vec4 { + let scene = textureSample(source_tex, linear_sampler, in.uv); + let halo = textureSample(auxiliary_tex, linear_sampler, in.uv).rgb; + return vec4(scene.rgb + halo * bloom.gain, scene.a); +} diff --git a/crates/benilla-app/src/post/mod.rs b/crates/benilla-app/src/post/mod.rs new file mode 100644 index 00000000..0fc62e25 --- /dev/null +++ b/crates/benilla-app/src/post/mod.rs @@ -0,0 +1,11 @@ +//! MONKEY (post): optional world-camera effects ahead of FFXGlow's gamma clamp. + +mod bloom; + +pub(crate) struct PostPlugin; + +impl bevy::prelude::Plugin for PostPlugin { + fn build(&self, app: &mut bevy::prelude::App) { + app.add_plugins(bloom::BloomPlugin); + } +} diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index 30a2b12f..adf4e587 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -469,8 +469,8 @@ impl Default for VideoConfig { water_quality: 1, // MONKEY (volumetric fog): default to the inexpensive atmosphere. volumetric_fog: 1, - // MONKEY (post): opt-in; the future High graphics preset selects tier 2. - bloom: 0, + // MONKEY (post): the shipped High graphics preset uses the full-resolution tier. + bloom: 2, lava_light_gain: 1.0, fire_flicker: 1.0, display: if windowed_env() { diff --git a/crates/benilla-world/src/ffx_glow.rs b/crates/benilla-world/src/ffx_glow.rs index 36e2b30e..c6f9ddf1 100644 --- a/crates/benilla-world/src/ffx_glow.rs +++ b/crates/benilla-world/src/ffx_glow.rs @@ -308,7 +308,8 @@ fn ensure_ffx_glow( // ---------------------------------------------------------------- render world #[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)] -struct FfxGlowLabel; +// MONKEY (post): world-only HDR passes explicitly order themselves before the legacy clamp. +pub struct FfxGlowLabel; /// The layouts, samplers, wave LUT and pipelines, built once at startup. #[derive(Resource)] From 6f30b5466b211500997ae1ec52c4117eb75f1d29 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 02:52:49 +0100 Subject: [PATCH 07/87] MONKEY (daylight): city daylight census instrument + daylight- capture scenarios Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/capture/scenarios.rs | 26 ++ crates/benilla-world/src/lighting/daylight.rs | 4 + .../src/lighting/daylight/census.rs | 224 ++++++++++++++++++ 3 files changed, 254 insertions(+) create mode 100644 crates/benilla-world/src/lighting/daylight/census.rs diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index d970a9fd..ff57a2af 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -1378,6 +1378,32 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ look: LAVA_RIVER_LOOK, minute: 0, ui: None, + }, // MONKEY (daylight): city interiors by day and terrain torch shadows at night. World coords + // from the census (`lighting::daylight::census`, Stormwind uid 10047, Ironforge uid 7706). + // The Gilded Rose ground floor (group g268, portal + aperture seeds). + Scenario { + name: "daylight-sw-inn", map: Some(MAP_AZEROTH), + eye: [-8871.0, 681.0, 99.8], look: [-8858.0, 668.0, 98.6], minute: 720, ui: None, + }, + // The Cathedral of Light nave (groups g135/g146, lit only by two EXT-class window batches). + Scenario { + name: "daylight-sw-cathedral", map: Some(MAP_AZEROTH), + eye: [-8556.0, 826.0, 109.0], look: [-8515.0, 862.0, 112.0], minute: 720, ui: None, + }, + // A Trade District shop (group g123, one exterior portal). + Scenario { + name: "daylight-sw-shop", map: Some(MAP_AZEROTH), + eye: [-8776.0, 722.0, 101.5], look: [-8764.0, 711.0, 100.2], minute: 720, ui: None, + }, + // Ironforge: the Great Forge hall (g64, no opening to the sky) and the gate hall (g7, the + // city's one exterior portal). + Scenario { + name: "daylight-if-forge", map: Some(MAP_AZEROTH), + eye: [-4930.0, -945.0, 503.5], look: [-4890.0, -985.0, 503.0], minute: 720, ui: None, + }, + Scenario { + name: "daylight-if-gate", map: Some(MAP_AZEROTH), + eye: [-4975.0, -895.0, 503.5], look: [-5005.0, -852.0, 506.0], minute: 720, ui: None, }, ]; diff --git a/crates/benilla-world/src/lighting/daylight.rs b/crates/benilla-world/src/lighting/daylight.rs index 27ee4a3c..014df956 100644 --- a/crates/benilla-world/src/lighting/daylight.rs +++ b/crates/benilla-world/src/lighting/daylight.rs @@ -2035,6 +2035,10 @@ pub(super) fn register(app: &mut App) { ); } +// MONKEY (daylight): the city census instrument. +#[cfg(test)] +mod census; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/benilla-world/src/lighting/daylight/census.rs b/crates/benilla-world/src/lighting/daylight/census.rs new file mode 100644 index 00000000..7dac8fe7 --- /dev/null +++ b/crates/benilla-world/src/lighting/daylight/census.rs @@ -0,0 +1,224 @@ +//! MONKEY (daylight): the city daylight census (test-only instrument). Loads a WMO root from the +//! install, runs the live selection rule over it and prints which interior rooms get a daylight +//! seed, which do not, and what each unseeded room authors (batch classes, WINDOW / SIDN +//! materials, portal neighbours), plus the cost of each seed pass. +//! +//! `WOW_DATA= cargo test -p benilla-world --lib lighting::daylight::census -- --ignored --nocapture` +//! `WOW_CENSUS_WMO=` picks the root (default: Stormwind then Ironforge). + +use super::*; +use benilla_formats::{open_chain, parse_wmo_portals, parse_wmo_root, wmo_group_header}; + +struct Loaded { + groups: Vec, + names: Vec, + portals: benilla_formats::WmoPortals, + slices: Vec<(u16, u16)>, + /// `(group, class, window, sidn, positions)` per render batch. + batches: Vec<(u16, benilla_formats::WmoBatchClass, bool, bool, Vec<[f32; 3]>)>, +} + +fn group_names(bytes: &[u8]) -> Vec { + let (mut mogn, mut mogi) = (None, None); + let mut o = 0usize; + while o + 8 <= bytes.len() { + let tag = [bytes[o + 3], bytes[o + 2], bytes[o + 1], bytes[o]]; + let n = u32::from_le_bytes(bytes[o + 4..o + 8].try_into().unwrap()) as usize; + let body = bytes.get(o + 8..o + 8 + n); + match (&tag, body) { + (b"MOGN", Some(b)) => mogn = Some(b), + (b"MOGI", Some(b)) => mogi = Some(b), + _ => {} + } + o += 8 + n; + } + let (Some(mogn), Some(mogi)) = (mogn, mogi) else { + return Vec::new(); + }; + mogi.chunks_exact(32) + .map(|r| { + let off = i32::from_le_bytes(r[28..32].try_into().unwrap()); + usize::try_from(off) + .ok() + .and_then(|off| mogn.get(off..)) + .map(|rest| { + let end = rest.iter().position(|&b| b == 0).unwrap_or(rest.len()); + String::from_utf8_lossy(&rest[..end]).into_owned() + }) + .unwrap_or_default() + }) + .collect() +} + +fn load(path: &str) -> Loaded { + let data = benilla_formats::wow_data().expect("no WoW install found (set $WOW_DATA)"); + let mut chain = open_chain(&data).expect("open MPQ chain"); + let root_path = path.replace('/', "\\").to_ascii_lowercase(); + let bytes = chain.read_file(&root_path).expect("read root"); + let root = parse_wmo_root(&bytes).expect("parse root"); + let stem = root_path.strip_suffix(".wmo").unwrap().to_string(); + let mut slices = Vec::new(); + let mut batches = Vec::new(); + for gi in 0..root.group_count() { + let Ok(gbytes) = chain.read_file(&format!("{stem}_{gi:03}.wmo")) else { + slices.push((0, 0)); + continue; + }; + let h = wmo_group_header(&gbytes); + slices.push(h.map_or((0, 0), |h| (h.portal_ref_start, h.portal_ref_count))); + for s in benilla_formats::wmo_group_submeshes(&gbytes, &root).unwrap_or_default() { + if let Some(class) = s.wmo_batch { + batches.push((gi as u16, class, s.window, s.sidn.is_some(), s.positions)); + } + } + } + Loaded { + groups: root.group_infos().to_vec(), + names: group_names(&bytes), + portals: parse_wmo_portals(&bytes), + slices, + batches, + } +} + +fn census(path: &str) { + let l = load(path); + let graph = PortalGraph { + vertices: &l.portals.vertices, + infos: &l.portals.infos, + refs: &l.portals.refs, + slices: &l.slices, + }; + let feed: Vec<(u16, bool, &[[f32; 3]])> = l + .batches + .iter() + .map(|(g, c, _, _, p)| (*g, matches!(c, benilla_formats::WmoBatchClass::Ext), &p[..])) + .collect(); + let verts: usize = feed.iter().map(|b| b.2.len()).sum(); + let rooms = l.groups.iter().filter(|g| g.interior).count(); + println!("=== {path}: {} groups, {rooms} interior, {} batches, {verts} batch verts", l.groups.len(), feed.len()); + + let t = std::time::Instant::now(); + let ranked = daylight_seeds_ranked(&l.groups, graph, feed.iter().copied()); + let t_ranked = t.elapsed(); + let t = std::time::Instant::now(); + let bounds = boundary_clusters(&l.groups, &feed); + let t_bound = t.elapsed(); + let (day, bleed) = placement_openings(&l.groups, graph, feed.iter().copied()); + println!( + "live ranked seeds {} ({:.1} ms); boundary clusters if forced {} ({:.1} ms); kept {} day + {} bleed of budget {}", + ranked.len(), + t_ranked.as_secs_f64() * 1e3, + bounds.len(), + t_bound.as_secs_f64() * 1e3, + day.len(), + bleed.len(), + daylight_budget(&l.groups), + ); + let mut by_how: HashMap<&str, usize> = HashMap::new(); + for s in &day { + *by_how.entry(s.how.tag()).or_default() += 1; + } + println!("kept daylight by rule: {by_how:?}"); + // Which rooms each lane actually REACHES: the fixture's own claim set (containment, portal hop, + // split), the same set the packer's room gate enforces. + let mut day_claim: HashMap> = HashMap::new(); + for s in &day { + for c in daylight_claims(&l.groups, graph, s) { + day_claim + .entry(c.group) + .or_default() + .push(format!("{}@g{}{:?}", s.how.tag(), s.group, c.how)); + } + } + let mut bleed_claim: HashSet = HashSet::new(); + for s in &bleed { + for c in s.claims(&l.groups, graph) { + bleed_claim.insert(c.group); + } + } + let bound_groups: HashSet = bounds.iter().map(|b| b.0).collect(); + let place = placement(); + let (mut n_day, mut n_bleed, mut n_none) = (0, 0, 0); + for (gi, g) in l.groups.iter().enumerate() { + if !g.interior { + continue; + } + let gid = gi as u16; + let tag = if day_claim.contains_key(&gid) { + n_day += 1; + "DAY " + } else if bleed_claim.contains(&gid) { + n_bleed += 1; + "bleed" + } else { + n_none += 1; + "NONE " + }; + let (start, count) = l.slices[gi]; + let mut nb = Vec::new(); + for r in l.portals.refs.iter().skip(start as usize).take(count as usize) { + let other = l.groups.get(r.group as usize).map_or('?', |o| if o.interior { 'i' } else { 'e' }); + let area = benilla_formats::room_claim::portal_area(&graph, r.portal).unwrap_or(0.0); + nb.push(format!("p{}->g{}{}({:.0})", r.portal, r.group, other, area)); + } + let (mut e, mut w) = (0, 0); + for (bg, c, win, _, _) in &l.batches { + if usize::from(*bg) == gi { + e += usize::from(matches!(c, benilla_formats::WmoBatchClass::Ext)); + w += usize::from(*win); + } + } + let c = [ + 0.5 * (g.bbox_min[0] + g.bbox_max[0]), + 0.5 * (g.bbox_min[1] + g.bbox_max[1]), + 0.5 * (g.bbox_min[2] + g.bbox_max[2]), + ]; + let wc = place.map_or(c, |m| { + benilla_assets::coords::bevy_to_wow( + m.transform_point3(benilla_assets::coords::wow_to_bevy(c)), + ) + }); + println!( + " {tag} g{gi:<3} {:<18} at ({:8.1},{:7.1},{:6.1}) size ({:5.1},{:5.1},{:5.1}) E{e} W{w}{} {} {}", + l.names.get(gi).map(String::as_str).unwrap_or("-"), + wc[0], wc[1], wc[2], + g.bbox_max[0] - g.bbox_min[0], + g.bbox_max[1] - g.bbox_min[1], + g.bbox_max[2] - g.bbox_min[2], + if bound_groups.contains(&gid) { " stitch" } else { "" }, + nb.join(" "), + day_claim.get(&gid).map(|v| v.iter().take(3).cloned().collect::>().join(",")).unwrap_or_default(), + ); + } + println!("interior rooms: {n_day} reached by daylight, {n_bleed} by bleed only, {n_none} by neither"); +} + +/// `WOW_CENSUS_PLACE=map,tx,ty,uid`: the MODF placement, for world coordinates in the table. +fn placement() -> Option { + let spec = std::env::var("WOW_CENSUS_PLACE").ok()?; + let p: Vec<&str> = spec.split(',').collect(); + let (map, tx, ty, uid) = (p[0], p[1].parse().ok()?, p[2].parse().ok()?, p[3].parse::().ok()?); + let data = benilla_formats::wow_data()?; + let mut chain = open_chain(&data).ok()?; + let tile = benilla_formats::load_tile_mesh(&mut chain, map, tx, ty).ok()?; + let w = tile.wmos.iter().find(|w| w.unique_id == uid)?; + println!("placement uid {uid} pos {:?} rot {:?}", w.position, w.rotation); + Some(bevy::math::Affine3A::from_scale_rotation_translation( + Vec3::ONE, + benilla_assets::coords::placement_rotation(w.rotation), + benilla_assets::coords::wow_to_bevy(w.position), + )) +} + +#[test] +#[ignore = "instrument: needs WOW_DATA; run by hand with --ignored --nocapture"] +fn city_daylight_census() { + match std::env::var("WOW_CENSUS_WMO") { + Ok(p) => census(&p), + Err(_) => { + census(r"World\wmo\Azeroth\Buildings\Stormwind\Stormwind.wmo"); + census(r"World\wmo\KhazModan\Cities\Ironforge\ironforge.wmo"); + } + } +} From adc9d277e7b6d84826292fbffc9028231321e2be Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 02:52:49 +0100 Subject: [PATCH 08/87] MONKEY (daylight): terrain as a caster in exterior torch cube maps (torchTerrainShadows, default off) Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/assets/ui/OptionsFrame.xml | 38 +++++- crates/benilla-app/src/cvars.rs | 12 +- crates/benilla-app/src/torch_shadow.rs | 47 ++++++- .../src/ui_script/options_tests.rs | 5 + crates/benilla-app/src/video.rs | 8 ++ crates/benilla-world/src/terrain_stream.rs | 3 + .../src/terrain_stream/torch_terrain.rs | 115 ++++++++++++++++++ 7 files changed, 223 insertions(+), 5 deletions(-) create mode 100644 crates/benilla-world/src/terrain_stream/torch_terrain.rs diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 71e19e82..2598e806 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -513,6 +513,7 @@ interiorLight = "Dynamic Interior Lighting", interiorShadows = "Indoor Torch Shadows", exteriorShadows = "Outdoor Torch Shadows", + torchTerrainShadows = "Terrain Blocks Torchlight", torchSoft = "Torch Shadow Softness", fireLight = "Fire Light Brightness", fireFlicker = "Fire Flicker", @@ -578,6 +579,13 @@ "The same for fires out in the open: campfires, braziers and lampposts " .. "cast real shadows after dark.\n\n" .. "It does nothing by day and does not require Dynamic Interior Lighting.", + TORCH_TERRAIN_SHADOWS = + "Lets the ground itself take part in outdoor torch shadows: a hill or a " + .. "bank between a fire and the slope behind it blocks the fire's light. + +" + .. "Adds the nearby terrain to each outdoor fire's shadow map when it is " + .. "rebuilt; the per-frame cost is small. Needs Outdoor Torch Shadows.", TORCH_SOFT = "How soft the edge of a torch shadow is where the object meets the " .. "ground. Low is hard and crisp; high spreads the edge the way a real " @@ -637,6 +645,7 @@ interiorLight = "Динамическое освещение помещений", interiorShadows = "Тени факелов в помещениях", exteriorShadows = "Тени огней под открытым небом", + torchTerrainShadows = "Рельеф заслоняет огни", torchSoft = "Мягкость теней от огня", fireLight = "Яркость огня", fireFlicker = "Мерцание огня", @@ -710,6 +719,14 @@ "То же для огней под открытым небом: костры, жаровни и фонари " .. "отбрасывают настоящие тени после наступления темноты.\n\n" .. "Днём не действует и не требует динамического освещения помещений.", + TORCH_TERRAIN_SHADOWS = + "Земля сама участвует в тенях от огней снаружи: холм или склон между " + .. "костром и местностью за ним заслоняет свет огня. + +" + .. "Добавляет ближний рельеф в карту теней каждого уличного огня при её " + .. "перестройке; затраты на кадр невелики. Требует теней огней под " + .. "открытым небом.", TORCH_SOFT = "Насколько мягок край тени от огня там, где предмет касается пола. " .. "Низкое значение — резкий край, высокое — размытый, как от " @@ -798,7 +815,9 @@ "RowLightingQuality", "RowWaterQuality", "RowVolumetricFog", "RowCharacterShadows", "RowWorldShadows", "RowShadowDistance", "RowShadowResolution", "RowMoonShadows", - "RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft", + "RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", + -- MONKEY (daylight: terrain torch casters) + "RowTorchTerrainShadows", "RowTorchSoft", "RowFireLight", "RowFireFlicker", "RowSpellLights", "RowLavaGlow", "RowNightDarkness", "RowInteriorDarkness", }, @@ -1315,6 +1334,9 @@ local torch = (interior and GetCVar("interiorShadows") ~= "0") or GetCVar("exteriorShadows") ~= "0" OptionsRow_SetEnabled(getglobal(base .. "RowTorchSoft"), torch) + -- MONKEY (daylight: terrain torch casters): the ground only casts for outdoor fires. + OptionsRow_SetEnabled(getglobal(base .. "RowTorchTerrainShadows"), + GetCVar("exteriorShadows") ~= "0") return end if key == "Controls" then @@ -2721,11 +2743,23 @@ "BENILLA_TOOLTIP_EXTERIOR_SHADOWS") - + + + + OptionsRow_OnLoad(self, "torchTerrainShadows", + BENILLA_ADVGFX.torchTerrainShadows, + "BENILLA_TOOLTIP_TORCH_TERRAIN_SHADOWS") + + + + + + + -- 0.5..3, the CVar's own clamp verbatim: it is a -- SCALE on the PCF tap radius at a contact, so the diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 31a7eced..2235a622 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -1070,6 +1070,12 @@ pub(crate) const REGISTERED: &[Registered] = &[ "1", "benilla's own: outdoor fire lights (campfires, braziers, lampposts) cast real shadows at night; no effect by day", ), + // MONKEY (daylight: terrain torch casters): the ground as a torch caster. + ours( + "torchTerrainShadows", + "0", + "benilla's own: the ground casts into outdoor fire shadows (hills and banks block a fire's light); needs exteriorShadows", + ), // MONKEY (static torch cache): residency and per-frame work have separate live budgets. ours( "interiorShadowCasters", @@ -2898,7 +2904,7 @@ mod tests { let shadows = VideoConfig::default(); let flag = |b: bool| if b { 1.0 } else { 0.0 }; // MONKEY (volumetric fog): include the atmospheric tier in this fixed-size default table. - let lighting: [(&str, f32); 33] = [ + let lighting: [(&str, f32); 34] = [ ("waterQuality", shadows.water_quality as f32), // MONKEY (volumetric fog): weld registry and renderer defaults. ("volumetricFog", shadows.volumetric_fog as f32), @@ -2926,6 +2932,8 @@ mod tests { // MONKEY (torch shadows): the cube-map lane, indoors and out. ("interiorShadows", flag(shadows.interior_shadows)), ("exteriorShadows", flag(shadows.exterior_shadows)), + // MONKEY (daylight: terrain torch casters) + ("torchTerrainShadows", flag(shadows.torch_terrain_shadows)), ( "interiorShadowCasters", shadows.interior_shadow_casters as f32, @@ -2957,7 +2965,7 @@ mod tests { // row the client does not have; the length is the other half — 33 rows, 33 welds. let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect(); // MONKEY (volumetric fog): the atmospheric tier joins the default-consumer weld. - assert_eq!(welded.len(), 33, "the lighting lane welds 33 distinct rows"); + assert_eq!(welded.len(), 34, "the lighting lane welds 34 distinct rows"); for name in &welded { assert!( REGISTERED.iter().any(|r| r.name == *name), diff --git a/crates/benilla-app/src/torch_shadow.rs b/crates/benilla-app/src/torch_shadow.rs index 26d8f3d7..6f31663b 100644 --- a/crates/benilla-app/src/torch_shadow.rs +++ b/crates/benilla-app/src/torch_shadow.rs @@ -645,6 +645,23 @@ struct EntityCasters<'w, 's> { /// bundle rather than being a 17th system param because `update_torch_shadows` already sits at /// Bevy's 16-param ceiling — the same reason `distance` is here. lighting: Res<'w, WowLighting>, + /// MONKEY (daylight: terrain torch casters): the resident ADT tiles, for the ground half of an + /// exterior slot's static caster mesh (`torchTerrainShadows`). `Option` so a scene with no + /// terrain streamer (the glue, a booth) is simply terrain-less. + terrain: Option>, + adt_tiles: Option>>, +} + +/// MONKEY (daylight: terrain torch casters): does an EXTERIOR slot's static map include the ground? +/// The cvar `torchTerrainShadows`, or `WOW_TORCH_TERRAIN=0|1` (read once) for hermetic captures. +fn terrain_casters_on(video: &VideoConfig) -> bool { + static ENV: std::sync::OnceLock> = std::sync::OnceLock::new(); + ENV.get_or_init(|| match std::env::var("WOW_TORCH_TERRAIN").ok().as_deref() { + Some("1") => Some(true), + Some("0") => Some(false), + _ => None, + }) + .unwrap_or(video.torch_terrain_shadows) } /// The INTERIOR lane is active when the dynamic-interior lane is on AND its shadow toggle is on. @@ -1123,9 +1140,22 @@ fn update_torch_shadows( // While this pair is unchanged the fixture-local source set cannot have changed either, so the // slot's walk is skipped outright — which is what turns "a full scene scan every frame, // forever, in a room where nothing ever moves" into "a scan the frame something does". + // MONKEY (daylight: terrain torch casters): the resident terrain set joins the retained + // scene's stamp, so a tile streaming in re-checks the exterior slots near it; the switch itself + // is folded in so toggling the cvar re-keys every slot. + let terrain_on = terrain_casters_on(&video); + let terrain_gen = match (&ents.terrain, &ents.adt_tiles) { + (Some(t), Some(a)) if terrain_on => { + benilla_world::terrain_stream::terrain_torch_generation(t, a) ^ 0x7e44_a1d0 + } + _ => 0, + }; let census = (slot_count > 0) .then(|| PartCensus { - gx: gx.as_ref().map_or(0, |gx| gx.torch_residency_generation()), + gx: gx + .as_ref() + .map_or(0, |gx| gx.torch_residency_generation()) + .wrapping_add(terrain_gen), parts: static_part_census(&ents), }) .unwrap_or_default(); @@ -1210,6 +1240,12 @@ fn update_torch_shadows( entity_key = entity_key.wrapping_add(part_hash.finish()); } entity_key.hash(&mut hash); + // MONKEY (daylight: terrain torch casters): settled EXTERIOR slots only. An interior + // fixture's room already occludes the ground, and a moving slot regathers every frame. + let with_terrain = terrain_gen != 0 && slot.exterior && !moving; + if with_terrain { + terrain_gen.hash(&mut hash); + } let key = hash.finish(); let dirty = slot.geometry_key != Some(key); if !dirty { @@ -1235,6 +1271,15 @@ fn update_torch_shadows( }); let (_, part_excluded) = collect_torch_entities(&ents, true, &gather, &mut positions, &mut indices); + if with_terrain { + if let (Some(t), Some(a)) = (&ents.terrain, &ents.adt_tiles) { + let tris = benilla_world::terrain_stream::append_terrain_torch_triangles( + t, a, slot.pos, range, &mut positions, &mut indices); + if std::env::var_os("WOW_TORCH_TRACE").is_some() { + info!("torch-terrain: slot {i} +{tris} ground tris within {range:.0} yd"); + } + } + } slot.trace_excluded = (gx_excluded, part_excluded); // MONKEY (moving fixture): a live slot MUTATES its mesh in place and keeps its asset // id; only a settled rebuild takes a fresh one. Two reasons. A new `Mesh` asset every diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 43d3f917..ff8b4d34 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -3051,6 +3051,11 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() { "BENILLA_TOOLTIP_EXTERIOR_SHADOWS", "AdvancedGraphicsRowExteriorShadows", ), + // MONKEY (daylight: terrain torch casters) + ( + "BENILLA_TOOLTIP_TORCH_TERRAIN_SHADOWS", + "AdvancedGraphicsRowTorchTerrainShadows", + ), ("BENILLA_TOOLTIP_TORCH_SOFT", "AdvancedGraphicsRowTorchSoft"), ("BENILLA_TOOLTIP_FIRE_LIGHT", "AdvancedGraphicsRowFireLight"), ( diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index 07c17824..bcc30b60 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -314,6 +314,11 @@ pub(crate) struct VideoConfig { /// It shares `interior_shadow_casters`' sixteen resident cube slots, capped at half of them /// (`torch_shadow::exterior_budget`) so a village square cannot evict an inn's candles. pub(crate) exterior_shadows: bool, + /// MONKEY (daylight: terrain torch casters): whether the GROUND casts into an exterior torch's + /// cube map (`torchTerrainShadows`, default off; High = on) — a hill or bank between a fire and + /// the slope behind it blocks the fire. Only settled exterior slots gather it + /// (`torch_shadow`); `0` leaves the torch lane exactly as it was. + pub(crate) torch_terrain_shadows: bool, /// MONKEY (static torch cache): resident fixture budget (1..16, default 12). Static /// geometry renders only on promotion/residency changes; lowering this fades extra slots out. pub(crate) interior_shadow_casters: u32, @@ -439,6 +444,7 @@ impl Default for VideoConfig { // MONKEY (outdoor torch shadows): on — a night campfire with no shadow is the thing // this lane exists to fix, and it costs nothing whenever the sun is up. exterior_shadows: true, + torch_terrain_shadows: false, // MONKEY (static torch cache): 12 resident maps, four moving-caster overlays. interior_shadow_casters: 12, interior_shadow_dynamic: 4, @@ -600,6 +606,8 @@ pub(crate) fn on_cvar( // reads `VideoConfig` every frame, so `0` fades the outdoor shadows out (the slots evict // through the same cross-fade a walked-away fixture does) and `1` fades them back in. "exteriorshadows" => cfg.exterior_shadows = ev.flag(), + // MONKEY (daylight: terrain torch casters) + "torchterrainshadows" => cfg.torch_terrain_shadows = ev.flag(), // MONKEY (torch caster selection): the working-set size and the PCF radius, clamped at the // edge like every other numeric row. `casters` floors at 1, not 0 — `interiorShadows 0` is // already the off switch, and a 0 here would be a second, confusing one. diff --git a/crates/benilla-world/src/terrain_stream.rs b/crates/benilla-world/src/terrain_stream.rs index 32822b4a..c6d3e353 100644 --- a/crates/benilla-world/src/terrain_stream.rs +++ b/crates/benilla-world/src/terrain_stream.rs @@ -34,6 +34,9 @@ mod queries; mod spawn; mod weld; pub(crate) mod window; +// MONKEY (daylight: terrain torch casters): resident MCNK chunks as torch cube-map casters. +mod torch_terrain; +pub use torch_terrain::{append_terrain_torch_triangles, terrain_torch_generation}; use collider::{finish_colliders, impassable_wall_data, terrain_collider_data}; use furnish::furnish_tile_cells; diff --git a/crates/benilla-world/src/terrain_stream/torch_terrain.rs b/crates/benilla-world/src/terrain_stream/torch_terrain.rs new file mode 100644 index 00000000..b58e2aa4 --- /dev/null +++ b/crates/benilla-world/src/terrain_stream/torch_terrain.rs @@ -0,0 +1,115 @@ +//! MONKEY (daylight: terrain torch casters): terrain as a CASTER in the torch cube maps. +//! +//! The torch lane's static caster mesh is gathered from the retained scene (`StaticGx`) and the +//! rigid model parts; the ground was never in it, so a hill or a bank between a campfire and the +//! slope behind it never blocked the fire. This module hands the gather the resident MCNK chunks +//! within a fixture's reach, as world-space triangles (hole-masked indices, draw space — the same +//! vertices `collider::terrain_collider_data` welds, so the depth map and the drawn ground agree). +//! +//! Budget: one chunk is 145 vertices / up to 256 triangles; a 48 yd sphere touches at most ~16 +//! chunks, so a slot gathers at most ~2.3k vertices and ~4k triangles once, when its static map +//! rebuilds. Chunks are culled by their own box against the sphere, then per triangle. + +use benilla_assets::coords::wow_to_bevy; +use benilla_assets::AdtTile; +use bevy::prelude::*; + +use super::TerrainStreamer; + +/// The terrain vertex bounds of one chunk in WoW axes. A chunk's positions are absolute world +/// yards, so the box is found without the tile index. +fn chunk_box(chunk: &benilla_formats::ChunkMesh) -> Option<(Vec3, Vec3)> { + let mut it = chunk.positions.iter(); + let first = Vec3::from(*it.next()?); + let (mut lo, mut hi) = (first, first); + for p in it { + let p = Vec3::from(*p); + lo = lo.min(p); + hi = hi.max(p); + } + Some((lo, hi)) +} + +fn box_sphere_d2(lo: Vec3, hi: Vec3, c: Vec3) -> f32 { + (c.clamp(lo, hi) - c).length_squared() +} + +/// Append every resident terrain triangle within `reach` of `center` (Bevy world space) to the +/// caster buffers, and return how many triangles were added. A triangle is kept when its own +/// box reaches the sphere — cheaper than an exact test and never drops a real occluder. +pub fn append_terrain_torch_triangles( + streamer: &TerrainStreamer, + adt_tiles: &Assets, + center: Vec3, + reach: f32, + positions: &mut Vec<[f32; 3]>, + indices: &mut Vec, +) -> u32 { + // Chunk boxes are in WoW axes, so test the sphere there (the conversion is a rigid swizzle). + let c = Vec3::from(benilla_assets::coords::bevy_to_wow(center)); + let r2 = reach * reach; + let mut added = 0u32; + for ts in streamer.tiles.values() { + let Some(adt) = adt_tiles.get(&ts.handle) else { + continue; + }; + for chunk in &adt.chunks { + let Some((lo, hi)) = chunk_box(chunk) else { + continue; + }; + if box_sphere_d2(lo, hi, c) > r2 { + continue; + } + let base = positions.len() as u32; + let mut used = false; + for tri in chunk.indices.as_chunks::<3>().0 { + let (Some(a), Some(b), Some(d)) = ( + chunk.positions.get(tri[0] as usize), + chunk.positions.get(tri[1] as usize), + chunk.positions.get(tri[2] as usize), + ) else { + continue; + }; + let (a, b, d) = (Vec3::from(*a), Vec3::from(*b), Vec3::from(*d)); + if box_sphere_d2(a.min(b).min(d), a.max(b).max(d), c) > r2 { + continue; + } + if !used { + positions.extend(chunk.positions.iter().map(|p| wow_to_bevy(*p).to_array())); + used = true; + } + indices.extend(tri.iter().map(|i| base + i)); + added += 1; + } + } + } + added +} + +/// A cheap stamp of the resident terrain set: which tiles are loaded (decoded) right now. Folded +/// into the torch lane's census and slot fingerprint so a tile streaming in or out re-gathers the +/// fixtures near it. Order-independent. +pub fn terrain_torch_generation(streamer: &TerrainStreamer, adt_tiles: &Assets) -> u64 { + use std::hash::{Hash, Hasher}; + let mut sum = 0u64; + for (key, ts) in &streamer.tiles { + let mut h = std::collections::hash_map::DefaultHasher::new(); + key.hash(&mut h); + ts.handle.id().hash(&mut h); + adt_tiles.contains(&ts.handle).hash(&mut h); + sum = sum.wrapping_add(h.finish()); + } + sum +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn box_sphere_distance_is_zero_inside_and_grows_outside() { + let (lo, hi) = (Vec3::ZERO, Vec3::ONE); + assert_eq!(box_sphere_d2(lo, hi, Vec3::splat(0.5)), 0.0); + assert!((box_sphere_d2(lo, hi, Vec3::new(3.0, 0.5, 0.5)) - 4.0).abs() < 1e-6); + } +} From ca667d2b354707ccbf3fc74f03a0def7995f4603 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 02:52:47 +0100 Subject: [PATCH 09/87] MONKEY (ao): depth-only half-res SSAO pass before the water copy, cvar ambientOcclusion + Advanced Graphics row Co-Authored-By: Claude Opus 5.5 --- LIGHTING.md | 1 + crates/benilla-app/assets/ui/OptionsFrame.xml | 36 ++ crates/benilla-app/src/capture/scenarios.rs | 15 + crates/benilla-app/src/cvars.rs | 5 + crates/benilla-app/src/game_plugins.rs | 2 + crates/benilla-app/src/lib.rs | 2 + crates/benilla-app/src/shaders/ssao.wgsl | 174 +++++++ crates/benilla-app/src/ssao.rs | 484 ++++++++++++++++++ .../src/ui_script/options_tests.rs | 40 +- crates/benilla-app/src/video.rs | 6 + crates/benilla-world/src/liquid/mod.rs | 3 +- .../benilla-world/src/liquid/scene_depth.rs | 3 +- 12 files changed, 768 insertions(+), 3 deletions(-) create mode 100644 crates/benilla-app/src/shaders/ssao.wgsl create mode 100644 crates/benilla-app/src/ssao.rs diff --git a/LIGHTING.md b/LIGHTING.md index 2122d3df..6dd43bc3 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -24,6 +24,7 @@ behind each constant. | Moon shadows | at night the same shadow rig re-aims at the moon and casts a faint shadow; dims only the night sky term, never point lights | `moonShadowStrength` | | Ground-effect spells | Flamestrike, Rain of Fire, Consecration, Flare and fire traps light the ground for their duration; frost and nature areas stay dark | `spellLightGain` | | Volumetric fog | near-field haze that converges on the zone fog colour (clear within 10 yd, full by 150 yd; mistier at dawn and in bad weather, faint indoors) and sun/moon light shafts through gaps, sampled from the shadow map; own fullscreen pass after the main pass (`benilla-app/src/volumetric_fog.rs`) | Advanced Graphics → Volumetric Fog (Off/Low/High), cvar `volumetricFog`, env `WOW_VOLFOG=0\|1\|2` | +| 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 35-80 yd, 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` shows the AO term alone | | Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` | Players reach all of it from **Options -> Advanced Graphics** (a Lighting Quality preset Off / Low / Medium / High plus the individual rows; Off is the original client look). The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default / diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 71e19e82..4e660703 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -519,6 +519,8 @@ spellLights = "Spell Lights", nightDarkness = "Night Darkness", interiorDarkness = "Interior Darkness", + -- MONKEY (ao): localised contact-shadow row. + ambientOcclusion = "Ambient Occlusion", yards = "%d yd", tips = { LIGHTING_QUALITY = @@ -611,6 +613,13 @@ "Scales interior fixture light and room ambient, including the light from " .. "lamps and candles. Lower values make both dimmer.\n\n" .. "1.0 leaves these inputs unchanged. Free at any setting.", + -- MONKEY (ao): what the contact shadows touch and what they spare. + AMBIENT_OCCLUSION = + "Soft contact shadows where surfaces meet: under props and furniture, in the corners " + .. "between walls and floors, and around characters' feet. Low uses fewer samples; " + .. "High is smoother and reaches a little further.\n\n" + .. "Fades out with distance and spares the sky, water, lit windows and flames. " + .. "Off leaves the image unchanged.", }, }, ruRU = { @@ -643,6 +652,8 @@ spellLights = "Свет от заклинаний", nightDarkness = "Темнота ночи", interiorDarkness = "Темнота помещений", + -- MONKEY (ao): Russian contact-shadow row. + ambientOcclusion = "Затенение окружения", yards = "%d ярд.", tips = { LIGHTING_QUALITY = @@ -743,6 +754,13 @@ "Масштабирует свет светильников и фоновое освещение помещений, включая " .. "свет ламп и свечей. Меньшие значения ослабляют и то и другое.\n\n" .. "1.0 оставляет эти составляющие без изменений. Ничего не стоит при любом значении.", + -- MONKEY (ao): Russian contact-shadow explanation. + AMBIENT_OCCLUSION = + "Мягкие контактные тени там, где поверхности соприкасаются: под предметами и мебелью, " + .. "в углах между стенами и полом, у ног персонажей. «Низкое» использует меньше выборок; " + .. "«Высокое» — более плавное и захватывает чуть больше.\n\n" + .. "Ослабевает с расстоянием и не затрагивает небо, воду, освещённые окна и пламя. " + .. "«Выкл» оставляет изображение без изменений.", }, }, } @@ -801,6 +819,8 @@ "RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft", "RowFireLight", "RowFireFlicker", "RowSpellLights", "RowLavaGlow", "RowNightDarkness", "RowInteriorDarkness", + -- MONKEY (ao): appended so the other rows keep their indices. + "RowAmbientOcclusion", }, -- Player Guild Names is a child of Player Names, as in 1.12 (l.702-708). Nameplates = { @@ -2827,6 +2847,22 @@ OptionsSliderRow_Setup(self, 0.2, 1.5, 0.05, "darkness") + + + + + + + + OptionsRow_OnLoad(self, "ambientOcclusion", BENILLA_ADVGFX.ambientOcclusion, + "BENILLA_TOOLTIP_AMBIENT_OCCLUSION") + OptionsDropdownRow_Setup(self, { + { text = BENILLA_ADVGFX.fogOff, value = "0" }, + { text = BENILLA_ADVGFX.qLow, value = "1" }, + { text = BENILLA_ADVGFX.qHigh, value = "2" }, + }) + + + - + + OptionsRow_OnLoad(self, "sunShafts", BENILLA_ADVGFX.sunShafts, + "BENILLA_TOOLTIP_SUN_SHAFTS") + + + + + + + + OptionsRow_OnLoad(self, "characterShadows", BENILLA_ADVGFX.characterShadows, diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index ff3c06ce..2a4f80b5 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -923,6 +923,7 @@ pub(crate) const REGISTERED: &[Registered] = &[ ours("volumetricFog", "1", "benilla's own: near-field volumetric fog, 0 Off / 1 Low / 2 High"), // MONKEY (post): tier 0 leaves every emissive site and the frame byte-identical. ours("bloom", "2", "benilla's own: HDR emissive bloom, 0 Off / 1 Low / 2 High"), + ours("sunShafts", "1", "benilla's own: depth-occluded screen-space sun shafts"), ours( "waterQuality", "1", @@ -2012,6 +2013,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("volumetricFog", "0"), // MONKEY (post): no HDR lift or post pass is the byte-identical baseline. ("bloom", "0"), + ("sunShafts", "0"), ("lavaLightGain", "0"), ("fireLightGain", "0"), ("nightGain", "1.0"), @@ -2037,6 +2039,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("volumetricFog", "1"), // MONKEY (post): quarter-resolution halo. ("bloom", "1"), + ("sunShafts", "0"), ("lavaLightGain", "1"), ("fireLightGain", "1"), ("nightGain", "0.45"), @@ -2065,6 +2068,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("volumetricFog", "1"), // MONKEY (post): quarter-resolution halo. ("bloom", "1"), + ("sunShafts", "0"), ("lavaLightGain", "1"), ("fireLightGain", "1"), ("nightGain", "0.45"), @@ -2094,6 +2098,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("volumetricFog", "1"), // MONKEY (post): half-resolution halo. ("bloom", "2"), + ("sunShafts", "1"), ("lavaLightGain", "1"), ("fireLightGain", "1"), ("nightGain", "0.45"), @@ -2908,12 +2913,13 @@ mod tests { let shadows = VideoConfig::default(); let flag = |b: bool| if b { 1.0 } else { 0.0 }; // MONKEY (volumetric fog): include the atmospheric tier in this fixed-size default table. - let lighting: [(&str, f32); 33] = [ + let lighting: [(&str, f32); 35] = [ ("waterQuality", shadows.water_quality as f32), // MONKEY (volumetric fog): weld registry and renderer defaults. ("volumetricFog", shadows.volumetric_fog as f32), // MONKEY (post): weld registry and renderer defaults. ("bloom", shadows.bloom as f32), + ("sunShafts", flag(shadows.sun_shafts)), ("lavaLightGain", shadows.lava_light_gain), // The two sun lanes and the cascade they share. ("worldShadows", flag(shadows.world_shadows)), @@ -2966,10 +2972,10 @@ mod tests { assert_eq!(d[name], want, "{name}: registered default left the knob"); } // …and the census IS the row set. A name here that nothing registers would weld against a - // row the client does not have; the length is the other half — 33 rows, 33 welds. + // row the client does not have; the length is the other half — 35 rows, 35 welds. let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect(); // MONKEY (volumetric fog): the atmospheric tier joins the default-consumer weld. - assert_eq!(welded.len(), 33, "the lighting lane welds 33 distinct rows"); + assert_eq!(welded.len(), 35, "the lighting lane welds 35 distinct rows"); for name in &welded { assert!( REGISTERED.iter().any(|r| r.name == *name), @@ -3147,6 +3153,7 @@ mod tests { "volumetricFog" => video.volumetric_fog as f32, // MONKEY (post): the post lane's live tier. "bloom" => video.bloom as f32, + "sunShafts" => video.sun_shafts as u32 as f32, "lavaLightGain" => video.lava_light_gain, "fireLightGain" => video.fire_light_gain, "moonShadowStrength" => video.moon_shadow_strength, @@ -3999,7 +4006,7 @@ mod tests { strings.sort_unstable(); // the list is the claim, not where the rows sit in the table assert_eq!( strings, - vec!["gxApi", "gxResolution", "realmList", "realmName"] + vec!["gxApi", "gxResolution", "lightingQuality", "realmList", "realmName"] ); let default_of = |name: &str| { REGISTERED diff --git a/crates/benilla-app/src/post/mod.rs b/crates/benilla-app/src/post/mod.rs index 0fc62e25..3d61ee58 100644 --- a/crates/benilla-app/src/post/mod.rs +++ b/crates/benilla-app/src/post/mod.rs @@ -1,11 +1,13 @@ //! MONKEY (post): optional world-camera effects ahead of FFXGlow's gamma clamp. mod bloom; +mod sun_shafts; pub(crate) struct PostPlugin; impl bevy::prelude::Plugin for PostPlugin { fn build(&self, app: &mut bevy::prelude::App) { app.add_plugins(bloom::BloomPlugin); + app.add_plugins(sun_shafts::SunShaftsPlugin); } } diff --git a/crates/benilla-app/src/post/sun_shafts.rs b/crates/benilla-app/src/post/sun_shafts.rs new file mode 100644 index 00000000..f509fd48 --- /dev/null +++ b/crates/benilla-app/src/post/sun_shafts.rs @@ -0,0 +1,315 @@ +//! MONKEY (post): screen-space sun shafts from the visible sky depth. + +use super::bloom::BloomLabel; +use crate::video::VideoConfig; +use benilla_world::{lighting::WowLighting, view::WorldCamera, wmo_portal::CameraInteriorClaim}; +use bevy::{ + core_pipeline::{ + core_3d::graph::{Core3d, Node3d}, + FullscreenShader, + }, + ecs::query::QueryItem, + prelude::*, + render::{ + diagnostic::RecordDiagnostics, + extract_component::{ExtractComponent, ExtractComponentPlugin}, + render_graph::{ + NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner, + }, + render_resource::{binding_types::*, *}, + renderer::{RenderContext, RenderDevice, RenderQueue}, + view::{Msaa, ViewDepthTexture, ViewTarget}, + Render, RenderApp, RenderStartup, RenderSystems, + }, +}; + +pub(super) struct SunShaftsPlugin; + +#[derive(Component, Clone, Copy, PartialEq, ExtractComponent, ShaderType)] +struct ShaftView { + // xy = sun UV, z = faded strength, w = sample count. + sun: Vec4, + // Gamma-space sun colour; the framebuffer is still gamma-space here. + color: Vec4, +} + +impl Plugin for SunShaftsPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(ExtractComponentPlugin::::default()) + .add_systems(Last, update_views); + if !app.is_plugin_added::() { + return; + } + let shader = app + .world_mut() + .resource_mut::>() + .add(Shader::from_wgsl( + include_str!("sun_shafts.wgsl"), + "post/sun_shafts.wgsl", + )); + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + render_app + .insert_resource(ShaftShader(shader)) + .init_resource::>() + .add_systems(RenderStartup, init_pipeline) + .add_systems(Render, prepare_pipelines.in_set(RenderSystems::Prepare)) + .add_render_graph_node::>(Core3d, ShaftLabel) + .add_render_graph_edges( + Core3d, + (Node3d::StartMainPassPostProcessing, ShaftLabel, BloomLabel), + ); + } +} + +fn update_views( + mut commands: Commands, + video: Res, + lighting: Res, + interior: Res, + mut cameras: Query< + ( + Entity, + &Camera, + &GlobalTransform, + &Projection, + &mut Camera3d, + Option<&ShaftView>, + ), + With, + >, +) { + let to_sun = lighting.celestial_dir().normalize_or_zero(); + for (entity, camera, transform, projection, mut camera3d, old) in &mut cameras { + let disabled = + !video.sun_shafts || !camera.is_active || interior.0.is_some() || to_sun == Vec3::ZERO; + if disabled { + if old.is_some() { + commands.entity(entity).remove::(); + } + continue; + } + let far = match projection { + Projection::Perspective(p) => p.far, + _ => 3000.0, + }; + let world = transform.translation() + to_sun * far * 0.85; + let (Ok(screen), Some(size)) = ( + camera.world_to_viewport(transform, world), + camera.logical_viewport_size(), + ) else { + commands.entity(entity).remove::(); + continue; + }; + let uv = screen / size; + // Keep a small off-screen skirt so shafts leave the frame continuously. + let outside = Vec2::new( + (-uv.x).max(uv.x - 1.0).max(0.0), + (-uv.y).max(uv.y - 1.0).max(0.0), + ) + .max_element(); + let edge_fade = 1.0 - smoothstep(0.0, 0.18, outside); + let daylight = smoothstep(-0.02, 0.16, to_sun.y); + let strength = edge_fade * daylight; + if strength <= 0.001 { + commands.entity(entity).remove::(); + continue; + } + camera3d.depth_texture_usages.0 |= TextureUsages::TEXTURE_BINDING.bits(); + let next = ShaftView { + sun: uv.extend(strength).extend(28.0), + color: Vec3::from_array(lighting.diffuse) + .lerp(Vec3::ONE, 0.18) + .extend(0.0), + }; + if old != Some(&next) { + commands.entity(entity).insert(next); + } + } +} + +fn smoothstep(a: f32, b: f32, value: f32) -> f32 { + let t = ((value - a) / (b - a)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +#[derive(Resource)] +struct ShaftShader(Handle); + +#[derive(Resource)] +struct ShaftPipeline { + layouts: [BindGroupLayoutDescriptor; 2], + shader: Handle, + fullscreen: FullscreenShader, + sampler: Sampler, +} + +#[derive(Component)] +struct ViewShaftPipeline(CachedRenderPipelineId); + +#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)] +struct ShaftLabel; + +fn init_pipeline( + mut commands: Commands, + device: Res, + shader: Res, + fullscreen: Res, +) { + let layouts = [false, true].map(|multisampled| { + BindGroupLayoutDescriptor::new( + "post_sun_shafts_layout", + &BindGroupLayoutEntries::sequential( + ShaderStages::FRAGMENT, + ( + texture_2d(TextureSampleType::Float { filterable: true }), + sampler(SamplerBindingType::Filtering), + if multisampled { + texture_depth_2d_multisampled() + } else { + texture_depth_2d() + }, + uniform_buffer::(false), + ), + ), + ) + }); + commands.insert_resource(ShaftPipeline { + layouts, + shader: shader.0.clone(), + fullscreen: fullscreen.clone(), + sampler: device.create_sampler(&SamplerDescriptor { + min_filter: FilterMode::Linear, + mag_filter: FilterMode::Linear, + address_mode_u: AddressMode::ClampToEdge, + address_mode_v: AddressMode::ClampToEdge, + ..default() + }), + }); +} + +impl SpecializedRenderPipeline for ShaftPipeline { + type Key = (TextureFormat, bool); + + fn specialize(&self, (format, multisampled): Self::Key) -> RenderPipelineDescriptor { + RenderPipelineDescriptor { + label: Some("post_sun_shafts".into()), + layout: vec![self.layouts[multisampled as usize].clone()], + vertex: self.fullscreen.to_vertex_state(), + fragment: Some(FragmentState { + shader: self.shader.clone(), + shader_defs: multisampled + .then(|| "MULTISAMPLED".into()) + .into_iter() + .collect(), + entry_point: Some("fragment".into()), + targets: vec![Some(ColorTargetState { + format, + blend: None, + write_mask: ColorWrites::ALL, + })], + }), + ..default() + } + } +} + +fn prepare_pipelines( + mut commands: Commands, + cache: Res, + pipeline: Res, + mut specialized: ResMut>, + views: Query<(Entity, &ViewTarget, &Msaa), With>, +) { + for (entity, target, msaa) in &views { + let id = specialized.specialize( + &cache, + &pipeline, + (target.main_texture_format(), msaa.samples() > 1), + ); + commands.entity(entity).insert(ViewShaftPipeline(id)); + } +} + +#[derive(Default)] +struct ShaftNode; + +impl ViewNode for ShaftNode { + type ViewQuery = ( + &'static ViewTarget, + &'static ViewDepthTexture, + &'static ShaftView, + &'static ViewShaftPipeline, + ); + + fn run<'w>( + &self, + _graph: &mut RenderGraphContext, + context: &mut RenderContext<'w>, + (target, depth, shaft, id): QueryItem<'w, '_, Self::ViewQuery>, + world: &'w World, + ) -> Result<(), NodeRunError> { + let cache = world.resource::(); + let Some(pipeline) = cache.get_render_pipeline(id.0) else { + return Ok(()); + }; + if !depth + .texture + .usage() + .contains(TextureUsages::TEXTURE_BINDING) + { + return Ok(()); + } + let settings = world.resource::(); + let device = context.render_device(); + let mut uniform = UniformBuffer::from(*shaft); + uniform.write_buffer(device, world.resource::()); + let out = target.post_process_write(); + let bind = device.create_bind_group( + "post_sun_shafts", + &cache.get_bind_group_layout( + &settings.layouts[(depth.texture.sample_count() > 1) as usize], + ), + &BindGroupEntries::sequential(( + out.source, + &settings.sampler, + depth.view(), + uniform.binding().unwrap(), + )), + ); + let diagnostics = context.diagnostic_recorder(); + let mut pass = context + .command_encoder() + .begin_render_pass(&RenderPassDescriptor { + label: Some("post_sun_shafts"), + color_attachments: &[Some(RenderPassColorAttachment { + view: out.destination, + depth_slice: None, + resolve_target: None, + ops: Operations::default(), + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + let span = diagnostics.pass_span(&mut pass, "post_sun_shafts"); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &bind, &[]); + pass.draw(0..3, 0..1); + span.end(&mut pass); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn edge_and_night_fades_are_bounded() { + assert_eq!(smoothstep(0.0, 1.0, -1.0), 0.0); + assert_eq!(smoothstep(0.0, 1.0, 2.0), 1.0); + assert!((smoothstep(0.0, 1.0, 0.5) - 0.5).abs() < 1.0e-6); + } +} diff --git a/crates/benilla-app/src/post/sun_shafts.wgsl b/crates/benilla-app/src/post/sun_shafts.wgsl new file mode 100644 index 00000000..c511d20c --- /dev/null +++ b/crates/benilla-app/src/post/sun_shafts.wgsl @@ -0,0 +1,53 @@ +// MONKEY (post): screen-space radial blur through sky pixels in the scene depth. + +#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput + +@group(0) @binding(0) var scene: texture_2d; +@group(0) @binding(1) var scene_sampler: sampler; +#ifdef MULTISAMPLED +@group(0) @binding(2) var depth: texture_depth_multisampled_2d; +#else +@group(0) @binding(2) var depth: texture_depth_2d; +#endif + +struct Shafts { + sun: vec4, + color: vec4, +}; +@group(0) @binding(3) var shafts: Shafts; + +fn scene_depth(uv: vec2) -> f32 { + let dims = textureDimensions(depth); + let p = vec2(clamp(uv, vec2(0.0), vec2(0.999999)) * vec2(dims)); +#ifdef MULTISAMPLED + var d = 0.0; + for (var sample = 0u; sample < textureNumSamples(depth); sample++) { + // Reversed Z: any covered sample is enough to block the sky ray. + d = max(d, textureLoad(depth, p, i32(sample))); + } + return d; +#else + return textureLoad(depth, p, 0); +#endif +} + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let base = textureSample(scene, scene_sampler, in.uv); + let count = u32(shafts.sun.w); + let step_uv = (shafts.sun.xy - in.uv) * (0.88 / shafts.sun.w); + var uv = in.uv; + var decay = 1.0; + var light = 0.0; + var norm = 0.0; + for (var i = 0u; i < 32u; i++) { + if (i >= count) { break; } + uv += step_uv; + decay *= 0.965; + let sky = 1.0 - smoothstep(0.000002, 0.00008, scene_depth(uv)); + light += sky * decay; + norm += decay; + } + let rays = light / max(norm, 0.0001); + return vec4(base.rgb + shafts.color.rgb * rays * shafts.sun.z * 0.16, base.a); +} diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index adf4e587..d1492045 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -224,6 +224,8 @@ pub(crate) struct VideoConfig { pub(crate) volumetric_fog: u8, // MONKEY (post): emissive HDR + bloom quality, 0 Off / 1 Low / 2 High. pub(crate) bloom: u8, + // MONKEY (post): depth-occluded radial sun shafts. + pub(crate) sun_shafts: bool, /// Brightness of lava lighting its surroundings, 0..4; 0 disables the glow. /// Published to `benilla_world::lighting::LavaLightGain` by `dynamic_interior::bridge`. pub(crate) lava_light_gain: f32, @@ -471,6 +473,8 @@ impl Default for VideoConfig { volumetric_fog: 1, // MONKEY (post): the shipped High graphics preset uses the full-resolution tier. bloom: 2, + // MONKEY (post): part of the shipped High graphics preset. + sun_shafts: true, lava_light_gain: 1.0, fire_flicker: 1.0, display: if windowed_env() { @@ -553,6 +557,8 @@ pub(crate) fn on_cvar( "volumetricfog" => cfg.volumetric_fog = v.clamp(0.0, 2.0) as u8, // MONKEY (post): constrain UI/console writes to the supported bloom tiers. "bloom" => cfg.bloom = v.clamp(0.0, 2.0) as u8, + // MONKEY (post): the shafts lane is binary. + "sunshafts" => cfg.sun_shafts = v != 0.0, "lavalightgain" => cfg.lava_light_gain = v.clamp(0.0, 4.0), "worldshadows" => cfg.world_shadows = ev.flag(), "charactershadows" => cfg.character_shadows = ev.flag(), From 6b1af8338f8472e1c6c6c90fa4b8d74a88b4aac1 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 03:07:06 +0100 Subject: [PATCH 13/87] MONKEY (post): port zone colour grading Co-Authored-By: GPT-5.6 Sol (Codex) --- THIRD-PARTY.md | 8 + crates/benilla-app/assets/ui/OptionsFrame.xml | 23 +- crates/benilla-app/src/capture/scenarios.rs | 34 ++ crates/benilla-app/src/cvars.rs | 13 +- crates/benilla-app/src/post/grading.rs | 432 ++++++++++++++++++ crates/benilla-app/src/post/grading.wgsl | 35 ++ crates/benilla-app/src/post/mod.rs | 2 + crates/benilla-app/src/video.rs | 4 + crates/benilla-formats/src/lib.rs | 4 + .../benilla-formats/src/monkey_zone_grade.rs | 69 +++ 10 files changed, 618 insertions(+), 6 deletions(-) create mode 100644 THIRD-PARTY.md create mode 100644 crates/benilla-app/src/post/grading.rs create mode 100644 crates/benilla-app/src/post/grading.wgsl create mode 100644 crates/benilla-formats/src/monkey_zone_grade.rs diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md new file mode 100644 index 00000000..5550fdcc --- /dev/null +++ b/THIRD-PARTY.md @@ -0,0 +1,8 @@ +# Third-party notices + +## WarcraftXL colour grading + +The colour-grading pass in `crates/benilla-app/src/post/grading.rs` and `grading.wgsl` is ported +from WarcraftXL's `wxl-retail-grading` implementation, Copyright (C) 2026 WarcraftXL. The source +implementation is licensed under GPL-3.0-or-later. The port preserves its 32³ LUT convention, +strength control, and strip lookup mathematics while uploading the strip as a native 3D texture. diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index e8b7b0fa..de717b2d 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -497,6 +497,7 @@ -- MONKEY (post): HDR excess + halo quality. bloom = "Bloom", sunShafts = "Sun Shafts", + colorGrading = "Colour Grading", fogOff = "Off", waterQuality = "Water Quality", waterClassic = "Classic", @@ -540,6 +541,8 @@ "Makes fire, lava, spells and lit windows carry a soft halo. Low uses a smaller blur chain; High is smoother. Off leaves the original image unchanged.", SUN_SHAFTS = "Draws sunlight through gaps in trees and buildings. Scene depth blocks the rays; they fade at night, off-screen and indoors.", + COLOR_GRADING = + "Applies a subtle zone-specific colour palette to the 3D world and blends between its day and night looks. The interface is never graded.", VOLUMETRIC_FOG = "Off disables volumetric fog. Low adds nearby haze and sun or moon shafts with fewer steps; High uses more steps for smoother shafts. Mist is heavier at dawn and in bad weather, and subtle indoors. Shafts require shadows; with shadows off only haze remains.", WATER_QUALITY = @@ -627,6 +630,7 @@ -- MONKEY (post): Russian HDR excess + halo quality row. bloom = "Свечение", sunShafts = "Солнечные лучи", + colorGrading = "Цветокоррекция", -- MONKEY (volumetric fog): Russian atmospheric quality row. volumetricFog = "Объёмный туман", fogOff = "Выкл", @@ -673,6 +677,8 @@ "Добавляет мягкое сияние огню, лаве, заклинаниям и освещённым окнам. Низкое качество использует короткое размытие, высокое — более плавное. Выключение сохраняет исходное изображение.", SUN_SHAFTS = "Показывает солнечные лучи сквозь деревья и здания. Рельеф и здания перекрывают свет; ночью и в помещениях эффект исчезает.", + COLOR_GRADING = + "Применяет мягкую цветовую палитру зоны и плавно меняет дневной вариант на ночной. Интерфейс не изменяется.", -- MONKEY (volumetric fog): localised effect and cost explanation. VOLUMETRIC_FOG = "Выкл отключает объёмный туман. Низкое добавляет дымку вблизи и лучи солнца или луны с меньшим числом шагов; Высокое использует больше шагов для более плавных лучей. Туман гуще на рассвете и в непогоду, а в помещениях слабее. Для лучей нужны тени; без теней остаётся только дымка.", @@ -811,7 +817,7 @@ -- three of eighteen shadow settings and no hint that the other fifteen existed. AdvancedGraphics = { -- MONKEY (volumetric fog): include in refresh, defaults and search. - "RowLightingQuality", "RowWaterQuality", "RowVolumetricFog", "RowBloom", "RowSunShafts", + "RowLightingQuality", "RowWaterQuality", "RowVolumetricFog", "RowBloom", "RowSunShafts", "RowColorGrading", "RowCharacterShadows", "RowWorldShadows", "RowShadowDistance", "RowShadowResolution", "RowMoonShadows", "RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft", @@ -2645,11 +2651,22 @@ "BENILLA_TOOLTIP_SUN_SHAFTS") + + + + + + + + OptionsRow_OnLoad(self, "colorGrading", BENILLA_ADVGFX.colorGrading, + "BENILLA_TOOLTIP_COLOR_GRADING") + + - - + + OptionsRow_OnLoad(self, "characterShadows", diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index d970a9fd..ccc57ff2 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -1379,6 +1379,40 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ minute: 0, ui: None, }, + // MONKEY (post): lane-specific A/B and perf viewpoints. Kept at the end for merge isolation. + Scenario { + name: "post-lava-searing", + map: Some(MAP_AZEROTH), + eye: LAVA_RIVER_EYE, + look: LAVA_RIVER_LOOK, + minute: 0, + ui: None, + }, + Scenario { + name: "post-stormwind-night", + map: Some(MAP_AZEROTH), + eye: [-8833.38, 628.63, 96.0], + look: [-8809.1, 672.3, 94.0], + minute: 0, + ui: None, + }, + Scenario { + name: "post-sunshafts-noon", + map: Some(MAP_AZEROTH), + eye: WATER_EYE, + // Noon's 85-degree sun above the Goldshire trees (azimuth 45 degrees). + look: [-9508.5, -292.1, 369.7], + minute: 720, + ui: None, + }, + Scenario { + name: "post-duskwood-grade", + map: Some(MAP_AZEROTH), + eye: [-10580.0, -1200.0, 45.0], + look: [-10540.0, -1160.0, 30.0], + minute: 1260, + ui: None, + }, ]; /// The owner's reported vantage for the lava-glow report: `(-7048.8, -1000.6, 242.0)` facing diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 2a4f80b5..7d7c5a25 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -924,6 +924,7 @@ pub(crate) const REGISTERED: &[Registered] = &[ // MONKEY (post): tier 0 leaves every emissive site and the frame byte-identical. ours("bloom", "2", "benilla's own: HDR emissive bloom, 0 Off / 1 Low / 2 High"), ours("sunShafts", "1", "benilla's own: depth-occluded screen-space sun shafts"), + ours("colorGrading", "1", "benilla's own: zone and day/night 32-cube colour grade"), ours( "waterQuality", "1", @@ -2014,6 +2015,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ // MONKEY (post): no HDR lift or post pass is the byte-identical baseline. ("bloom", "0"), ("sunShafts", "0"), + ("colorGrading", "0"), ("lavaLightGain", "0"), ("fireLightGain", "0"), ("nightGain", "1.0"), @@ -2040,6 +2042,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ // MONKEY (post): quarter-resolution halo. ("bloom", "1"), ("sunShafts", "0"), + ("colorGrading", "0"), ("lavaLightGain", "1"), ("fireLightGain", "1"), ("nightGain", "0.45"), @@ -2069,6 +2072,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ // MONKEY (post): quarter-resolution halo. ("bloom", "1"), ("sunShafts", "0"), + ("colorGrading", "0"), ("lavaLightGain", "1"), ("fireLightGain", "1"), ("nightGain", "0.45"), @@ -2099,6 +2103,7 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ // MONKEY (post): half-resolution halo. ("bloom", "2"), ("sunShafts", "1"), + ("colorGrading", "1"), ("lavaLightGain", "1"), ("fireLightGain", "1"), ("nightGain", "0.45"), @@ -2913,13 +2918,14 @@ mod tests { let shadows = VideoConfig::default(); let flag = |b: bool| if b { 1.0 } else { 0.0 }; // MONKEY (volumetric fog): include the atmospheric tier in this fixed-size default table. - let lighting: [(&str, f32); 35] = [ + let lighting: [(&str, f32); 36] = [ ("waterQuality", shadows.water_quality as f32), // MONKEY (volumetric fog): weld registry and renderer defaults. ("volumetricFog", shadows.volumetric_fog as f32), // MONKEY (post): weld registry and renderer defaults. ("bloom", shadows.bloom as f32), ("sunShafts", flag(shadows.sun_shafts)), + ("colorGrading", flag(shadows.color_grading)), ("lavaLightGain", shadows.lava_light_gain), // The two sun lanes and the cascade they share. ("worldShadows", flag(shadows.world_shadows)), @@ -2972,10 +2978,10 @@ mod tests { assert_eq!(d[name], want, "{name}: registered default left the knob"); } // …and the census IS the row set. A name here that nothing registers would weld against a - // row the client does not have; the length is the other half — 35 rows, 35 welds. + // row the client does not have; the length is the other half — 36 rows, 36 welds. let welded: std::collections::BTreeSet<&str> = lighting.iter().map(|(n, _)| *n).collect(); // MONKEY (volumetric fog): the atmospheric tier joins the default-consumer weld. - assert_eq!(welded.len(), 35, "the lighting lane welds 35 distinct rows"); + assert_eq!(welded.len(), 36, "the lighting lane welds 36 distinct rows"); for name in &welded { assert!( REGISTERED.iter().any(|r| r.name == *name), @@ -3154,6 +3160,7 @@ mod tests { // MONKEY (post): the post lane's live tier. "bloom" => video.bloom as f32, "sunShafts" => video.sun_shafts as u32 as f32, + "colorGrading" => video.color_grading as u32 as f32, "lavaLightGain" => video.lava_light_gain, "fireLightGain" => video.fire_light_gain, "moonShadowStrength" => video.moon_shadow_strength, diff --git a/crates/benilla-app/src/post/grading.rs b/crates/benilla-app/src/post/grading.rs new file mode 100644 index 00000000..5a4dc42a --- /dev/null +++ b/crates/benilla-app/src/post/grading.rs @@ -0,0 +1,432 @@ +//! MONKEY (post): zone/day-night colour grading through a real 32³ GPU texture. +//! +//! Ported from WarcraftXL's `wxl-retail-grading` (Copyright (C) 2026 WarcraftXL, GPL-3.0-or-later). +//! Its 1024×32 BLP strip/two-bilinear-tap cube lookup becomes the equivalent hardware-trilinear +//! lookup after upload to a 3D texture. See `THIRD-PARTY.md`. + +use super::bloom::BloomLabel; +use crate::video::VideoConfig; +use benilla_assets::{AssetSet, LockRecover, WorldAssets}; +use benilla_world::{lighting::WorldTime, terrain_stream::CurrentArea, view::WorldCamera}; +use bevy::{ + asset::RenderAssetUsages, + core_pipeline::{core_3d::graph::Core3d, FullscreenShader}, + ecs::query::QueryItem, + image::Image, + prelude::*, + render::{ + diagnostic::RecordDiagnostics, + extract_component::{ExtractComponent, ExtractComponentPlugin}, + render_asset::RenderAssets, + render_graph::{ + NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner, + }, + render_resource::{binding_types::*, *}, + renderer::{RenderContext, RenderDevice, RenderQueue}, + texture::GpuImage, + view::ViewTarget, + RenderApp, RenderStartup, + }, +}; +use std::collections::{HashMap, HashSet}; + +pub(super) struct GradingPlugin; + +#[derive(Resource)] +struct GradeCatalog { + rows: benilla_formats::MonkeyZoneGrades, + luts: HashMap>, + identity: Handle, +} + +#[derive(Component, Clone, PartialEq, ExtractComponent)] +struct GradeView { + day: Handle, + night: Handle, + // x = authored strength, y = night blend. + control: Vec4, +} + +#[derive(Clone, Copy, ShaderType)] +struct GradeUniform { + control: Vec4, +} + +impl Plugin for GradingPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(ExtractComponentPlugin::::default()) + .add_systems(Last, update_views); + if !app.is_plugin_added::() { + return; + } + app.add_systems(Startup, load_catalog.after(AssetSet::Open)); + let shader = app + .world_mut() + .resource_mut::>() + .add(Shader::from_wgsl( + include_str!("grading.wgsl"), + "post/grading.wgsl", + )); + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + render_app + .insert_resource(GradeShader(shader)) + .add_systems(RenderStartup, init_pipeline) + .add_render_graph_node::>(Core3d, GradeLabel) + .add_render_graph_edges( + Core3d, + ( + BloomLabel, + GradeLabel, + benilla_world::ffx_glow::FfxGlowLabel, + ), + ); + } +} + +fn load_catalog( + mut commands: Commands, + assets: Option>, + mut images: ResMut>, +) { + let identity = images.add(volume_image(identity_cube())); + let Some(assets) = assets else { + commands.insert_resource(GradeCatalog { + rows: default(), + luts: default(), + identity, + }); + return; + }; + let mut chain = assets.chain.lock_recover(); + let loose_root = benilla_formats::wow_data(); + let loaded_rows = loose_root + .as_ref() + .map(|root| root.join("DBFilesClient").join("MonkeyZoneGrade.dbc")) + .filter(|path| path.is_file()) + .map(|path| { + std::fs::read(path) + .map_err(anyhow::Error::from) + .and_then(|bytes| benilla_formats::parse_monkey_zone_grades(&bytes)) + }) + .unwrap_or_else(|| benilla_formats::load_monkey_zone_grades(&mut chain)); + let rows = match loaded_rows { + Ok(rows) => rows, + Err(error) => { + info!("post grading: no MonkeyZoneGrade.dbc; identity fallback ({error:#})"); + commands.insert_resource(GradeCatalog { + rows: default(), + luts: default(), + identity, + }); + return; + } + }; + let paths: HashSet = rows + .rows() + .flat_map(|row| [row.day_lut.clone(), row.night_lut.clone()]) + .collect(); + let mut luts = HashMap::new(); + for path in paths { + let loose = loose_root + .as_ref() + .map(|root| root.join(path.replace('\\', "/"))) + .filter(|path| path.is_file()) + .map(std::fs::read) + .transpose() + .map_err(anyhow::Error::from); + let result = loose + .and_then(|bytes| match bytes { + Some(bytes) => Ok(bytes), + None => chain.read_file(&path.replace('/', "\\")), + }) + .and_then(|bytes| benilla_formats::blp_bytes_to_mip_chain(&bytes)); + match result.and_then(|blp| strip_to_cube(&blp)) { + Ok(cube) => { + luts.insert(path, images.add(volume_image(cube))); + } + Err(error) => warn!("post grading: {path} rejected; identity fallback: {error:#}"), + } + } + info!( + "post grading: {} area rows, {} resident LUTs", + rows.rows().count(), + luts.len() + ); + commands.insert_resource(GradeCatalog { + rows, + luts, + identity, + }); +} + +fn strip_to_cube(blp: &benilla_formats::BlpMipChain) -> anyhow::Result> { + anyhow::ensure!( + blp.width == 1024 && blp.height == 32, + "expected 1024x32, got {}x{}", + blp.width, + blp.height + ); + anyhow::ensure!(!blp.mips.is_empty(), "BLP has no base mip"); + let strip = &blp.mips[0]; + anyhow::ensure!( + strip.len() == 1024 * 32 * 4, + "base mip has {} bytes", + strip.len() + ); + let mut cube = vec![0; 32 * 32 * 32 * 4]; + for b in 0..32usize { + for g in 0..32usize { + for r in 0..32usize { + let source = (g * 1024 + b * 32 + r) * 4; + let destination = ((b * 32 + g) * 32 + r) * 4; + cube[destination..destination + 4].copy_from_slice(&strip[source..source + 4]); + } + } + } + Ok(cube) +} + +fn identity_cube() -> Vec { + let mut cube = Vec::with_capacity(32 * 32 * 32 * 4); + for b in 0..32u32 { + for g in 0..32u32 { + for r in 0..32u32 { + cube.extend([r, g, b].map(|v| ((v * 255 + 15) / 31) as u8)); + cube.push(255); + } + } + } + cube +} + +fn volume_image(cube: Vec) -> Image { + Image::new( + Extent3d { + width: 32, + height: 32, + depth_or_array_layers: 32, + }, + TextureDimension::D3, + cube, + TextureFormat::Rgba8Unorm, + RenderAssetUsages::RENDER_WORLD, + ) +} + +fn update_views( + mut commands: Commands, + video: Res, + catalog: Option>, + area: Res, + areas: Option>, + time: Res, + cameras: Query<(Entity, &Camera, Option<&GradeView>), With>, +) { + let selected = catalog.as_ref().and_then(|catalog| { + let leaf = area.0?; + let zone = areas + .as_ref() + .and_then(|areas| areas.0.top_zone(leaf)) + .unwrap_or(leaf); + let row = catalog + .rows + .for_area(leaf) + .or_else(|| catalog.rows.for_area(zone))?; + (row.strength > 0.0).then(|| GradeView { + day: catalog + .luts + .get(&row.day_lut) + .unwrap_or(&catalog.identity) + .clone(), + night: catalog + .luts + .get(&row.night_lut) + .unwrap_or(&catalog.identity) + .clone(), + control: Vec4::new(row.strength, night_weight(time.minute_f), 0.0, 0.0), + }) + }); + for (entity, camera, old) in &cameras { + let next = (video.color_grading && camera.is_active) + .then(|| selected.clone()) + .flatten(); + match next { + Some(next) if old != Some(&next) => { + commands.entity(entity).insert(next); + } + None if old.is_some() => { + commands.entity(entity).remove::(); + } + _ => {} + } + } +} + +fn night_weight(minute: f32) -> f32 { + let dawn = smoothstep(330.0, 450.0, minute); + let dusk = smoothstep(1110.0, 1230.0, minute); + 1.0 - dawn * (1.0 - dusk) +} + +fn smoothstep(a: f32, b: f32, value: f32) -> f32 { + let t = ((value - a) / (b - a)).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +#[derive(Resource)] +struct GradeShader(Handle); + +#[derive(Resource)] +struct GradePipeline { + layout: BindGroupLayoutDescriptor, + scene_sampler: Sampler, + lut_sampler: Sampler, + pipeline: CachedRenderPipelineId, +} + +#[derive(Debug, Hash, PartialEq, Eq, Clone, RenderLabel)] +struct GradeLabel; + +fn init_pipeline( + mut commands: Commands, + device: Res, + cache: Res, + shader: Res, + fullscreen: Res, +) { + let layout = BindGroupLayoutDescriptor::new( + "post_grading_layout", + &BindGroupLayoutEntries::sequential( + ShaderStages::FRAGMENT, + ( + texture_2d(TextureSampleType::Float { filterable: true }), + sampler(SamplerBindingType::Filtering), + texture_3d(TextureSampleType::Float { filterable: true }), + texture_3d(TextureSampleType::Float { filterable: true }), + sampler(SamplerBindingType::Filtering), + uniform_buffer::(false), + ), + ), + ); + let pipeline = cache.queue_render_pipeline(RenderPipelineDescriptor { + label: Some("post_grading".into()), + layout: vec![layout.clone()], + vertex: fullscreen.to_vertex_state(), + fragment: Some(FragmentState { + shader: shader.0.clone(), + shader_defs: vec![], + entry_point: Some("fragment".into()), + targets: vec![Some(ColorTargetState { + format: ViewTarget::TEXTURE_FORMAT_HDR, + blend: None, + write_mask: ColorWrites::ALL, + })], + }), + ..default() + }); + let sampler = |filter| { + device.create_sampler(&SamplerDescriptor { + min_filter: filter, + mag_filter: filter, + address_mode_u: AddressMode::ClampToEdge, + address_mode_v: AddressMode::ClampToEdge, + address_mode_w: AddressMode::ClampToEdge, + ..default() + }) + }; + commands.insert_resource(GradePipeline { + layout, + scene_sampler: sampler(FilterMode::Nearest), + lut_sampler: sampler(FilterMode::Linear), + pipeline, + }); +} + +#[derive(Default)] +struct GradeNode; + +impl ViewNode for GradeNode { + type ViewQuery = (&'static ViewTarget, &'static GradeView); + + fn run<'w>( + &self, + _graph: &mut RenderGraphContext, + context: &mut RenderContext<'w>, + (target, grade): QueryItem<'w, '_, Self::ViewQuery>, + world: &'w World, + ) -> Result<(), NodeRunError> { + let settings = world.resource::(); + let cache = world.resource::(); + let Some(pipeline) = cache.get_render_pipeline(settings.pipeline) else { + return Ok(()); + }; + let images = world.resource::>(); + let (Some(day), Some(night)) = (images.get(&grade.day), images.get(&grade.night)) else { + return Ok(()); + }; + let device = context.render_device(); + let mut uniform = UniformBuffer::from(GradeUniform { + control: grade.control, + }); + uniform.write_buffer(device, world.resource::()); + let out = target.post_process_write(); + let bind = device.create_bind_group( + "post_grading", + &cache.get_bind_group_layout(&settings.layout), + &BindGroupEntries::sequential(( + out.source, + &settings.scene_sampler, + &day.texture_view, + &night.texture_view, + &settings.lut_sampler, + uniform.binding().unwrap(), + )), + ); + let diagnostics = context.diagnostic_recorder(); + let mut pass = context + .command_encoder() + .begin_render_pass(&RenderPassDescriptor { + label: Some("post_grading"), + color_attachments: &[Some(RenderPassColorAttachment { + view: out.destination, + depth_slice: None, + resolve_target: None, + ops: Operations::default(), + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + let span = diagnostics.pass_span(&mut pass, "post_grading"); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &bind, &[]); + pass.draw(0..3, 0..1); + span.end(&mut pass); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn identity_volume_has_exact_axes() { + let cube = identity_cube(); + let at = |r: usize, g: usize, b: usize| &cube[((b * 32 + g) * 32 + r) * 4..][..4]; + assert_eq!(at(0, 0, 0), &[0, 0, 0, 255]); + assert_eq!(at(31, 0, 0), &[255, 0, 0, 255]); + assert_eq!(at(0, 31, 0), &[0, 255, 0, 255]); + assert_eq!(at(0, 0, 31), &[0, 0, 255, 255]); + } + + #[test] + fn day_night_blend_is_continuous() { + assert_eq!(night_weight(0.0), 1.0); + assert_eq!(night_weight(720.0), 0.0); + assert_eq!(night_weight(1439.0), 1.0); + assert!((night_weight(390.0) - 0.5).abs() < 1.0e-6); + } +} diff --git a/crates/benilla-app/src/post/grading.wgsl b/crates/benilla-app/src/post/grading.wgsl new file mode 100644 index 00000000..fefdc9da --- /dev/null +++ b/crates/benilla-app/src/post/grading.wgsl @@ -0,0 +1,35 @@ +// Colour grading ported from WarcraftXL wxl-retail-grading. +// Copyright (C) 2026 WarcraftXL. GPL-3.0-or-later; see THIRD-PARTY.md. +// MONKEY (post): real 32³ textures make the original 1024x32 strip's two bilinear taps plus +// lerp(fract(b*31)) one hardware-trilinear lookup, for both the day and night cubes. + +#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput + +@group(0) @binding(0) var scene: texture_2d; +@group(0) @binding(1) var scene_sampler: sampler; +@group(0) @binding(2) var day_lut: texture_3d; +@group(0) @binding(3) var night_lut: texture_3d; +@group(0) @binding(4) var lut_sampler: sampler; + +struct Grade { + control: vec4, +}; +@group(0) @binding(5) var grade: Grade; + +fn grade_one(lut: texture_3d, color: vec3) -> vec3 { + // Half a texel in from each face, with 31 intervals across the 32-texel cube. + let uvw = color * (31.0 / 32.0) + vec3(0.5 / 32.0); + return textureSampleLevel(lut, lut_sampler, uvw, 0.0).rgb; +} + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let source = textureSampleLevel(scene, scene_sampler, in.uv, 0.0); + let base = clamp(source.rgb, vec3(0.0), vec3(1.0)); + let day = grade_one(day_lut, base); + let night = grade_one(night_lut, base); + let authored = mix(day, night, grade.control.y); + let graded = mix(base, authored, grade.control.x); + // Bloom already consumed the HDR signal; preserve its remaining excess for the FFX clamp. + return vec4(graded + max(source.rgb - vec3(1.0), vec3(0.0)), source.a); +} diff --git a/crates/benilla-app/src/post/mod.rs b/crates/benilla-app/src/post/mod.rs index 3d61ee58..2952e5a6 100644 --- a/crates/benilla-app/src/post/mod.rs +++ b/crates/benilla-app/src/post/mod.rs @@ -1,6 +1,7 @@ //! MONKEY (post): optional world-camera effects ahead of FFXGlow's gamma clamp. mod bloom; +mod grading; mod sun_shafts; pub(crate) struct PostPlugin; @@ -8,6 +9,7 @@ pub(crate) struct PostPlugin; impl bevy::prelude::Plugin for PostPlugin { fn build(&self, app: &mut bevy::prelude::App) { app.add_plugins(bloom::BloomPlugin); + app.add_plugins(grading::GradingPlugin); app.add_plugins(sun_shafts::SunShaftsPlugin); } } diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index d1492045..e440d6ac 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -226,6 +226,8 @@ pub(crate) struct VideoConfig { pub(crate) bloom: u8, // MONKEY (post): depth-occluded radial sun shafts. pub(crate) sun_shafts: bool, + // MONKEY (post): zone/day-night LUT grading at the world-to-UI boundary. + pub(crate) color_grading: bool, /// Brightness of lava lighting its surroundings, 0..4; 0 disables the glow. /// Published to `benilla_world::lighting::LavaLightGain` by `dynamic_interior::bridge`. pub(crate) lava_light_gain: f32, @@ -475,6 +477,7 @@ impl Default for VideoConfig { bloom: 2, // MONKEY (post): part of the shipped High graphics preset. sun_shafts: true, + color_grading: true, lava_light_gain: 1.0, fire_flicker: 1.0, display: if windowed_env() { @@ -559,6 +562,7 @@ pub(crate) fn on_cvar( "bloom" => cfg.bloom = v.clamp(0.0, 2.0) as u8, // MONKEY (post): the shafts lane is binary. "sunshafts" => cfg.sun_shafts = v != 0.0, + "colorgrading" => cfg.color_grading = v != 0.0, "lavalightgain" => cfg.lava_light_gain = v.clamp(0.0, 4.0), "worldshadows" => cfg.world_shadows = ev.flag(), "charactershadows" => cfg.character_shadows = ev.flag(), diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index ad87a31e..fb9d9216 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -29,6 +29,10 @@ mod camera_shakes; mod cinematics; mod creatures; mod dbc; +mod monkey_zone_grade; +pub use monkey_zone_grade::{ + load_monkey_zone_grades, parse_monkey_zone_grades, MonkeyZoneGrade, MonkeyZoneGrades, +}; mod unit_blood; pub use camera_shakes::{load_camera_shakes, CameraShake, CameraShakeCatalog, SpellShakeGroup}; pub use cinematics::{ diff --git a/crates/benilla-formats/src/monkey_zone_grade.rs b/crates/benilla-formats/src/monkey_zone_grade.rs new file mode 100644 index 00000000..2c690d92 --- /dev/null +++ b/crates/benilla-formats/src/monkey_zone_grade.rs @@ -0,0 +1,69 @@ +//! MONKEY (post): `MonkeyZoneGrade.dbc`, the optional area-to-day/night LUT table. + +use anyhow::{Context, Result}; +use benilla_dbc::{FieldType, Schema, SchemaField}; + +use crate::dbc::{f32_at, parse, str_at, u32_at}; +use crate::Chain; + +const TABLE: &str = "DBFilesClient\\MonkeyZoneGrade.dbc"; + +/// One authored zone grade. Missing files and absent area rows are handled by the caller as the +/// identity cube, so this type only represents complete rows. +#[derive(Clone, Debug, PartialEq)] +pub struct MonkeyZoneGrade { + pub id: u32, + pub area_id: u32, + pub day_lut: String, + pub night_lut: String, + pub strength: f32, +} + +#[derive(Clone, Debug, Default)] +pub struct MonkeyZoneGrades { + rows: Vec, +} + +impl MonkeyZoneGrades { + pub fn for_area(&self, area_id: u32) -> Option<&MonkeyZoneGrade> { + self.rows.iter().find(|row| row.area_id == area_id) + } + + pub fn rows(&self) -> impl Iterator { + self.rows.iter() + } +} + +/// Read the five-column extension table through the same typed DBC parser as the stock tables. +pub fn load_monkey_zone_grades(chain: &mut Chain) -> Result { + let bytes = chain + .read_file(TABLE) + .context("reading MonkeyZoneGrade.dbc")?; + parse_monkey_zone_grades(&bytes) +} + +/// Parse an in-memory extension table through the typed DBC reader. This is used for the loose +/// capture patch; the MPQ-backed path above and this path therefore share the exact schema. +pub fn parse_monkey_zone_grades(bytes: &[u8]) -> Result { + let mut schema = Schema::new("MonkeyZoneGrade"); + schema.add_field(SchemaField::new("ID", FieldType::UInt32)); + schema.add_field(SchemaField::new("AreaID", FieldType::UInt32)); + schema.add_field(SchemaField::new("DayLUT", FieldType::String)); + schema.add_field(SchemaField::new("NightLUT", FieldType::String)); + schema.add_field(SchemaField::new("Strength", FieldType::Float32)); + let records = parse(bytes, schema, "MonkeyZoneGrade.dbc")?; + let rows = records + .records() + .iter() + .filter_map(|record| { + Some(MonkeyZoneGrade { + id: u32_at(record, 0)?, + area_id: u32_at(record, 1)?, + day_lut: str_at(&records, record, 2)?, + night_lut: str_at(&records, record, 3)?, + strength: f32_at(record, 4).unwrap_or(0.0).clamp(0.0, 1.0), + }) + }) + .collect(); + Ok(MonkeyZoneGrades { rows }) +} From 0489b5c168d9e8dededc7b47dcf58376ff13dc68 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 03:11:31 +0100 Subject: [PATCH 14/87] MONKEY (water): restore WMO canal and pool rendering Co-Authored-By: GPT-5.6 Sol (Codex) --- WATER.md | 10 +- .../src/shaders/enhanced_water.wgsl | 96 +++++++++++-------- crates/benilla-assets/src/shaders/liquid.wgsl | 4 +- crates/benilla-world/src/liquid/surface.rs | 47 ++++++++- crates/benilla-world/src/liquid/waves.rs | 4 +- 5 files changed, 109 insertions(+), 52 deletions(-) diff --git a/WATER.md b/WATER.md index 9ee06550..f41a518d 100644 --- a/WATER.md +++ b/WATER.md @@ -2,7 +2,7 @@ An optional enhanced water renderer for [benilla](https://github.com/samwhosung/benilla), the Rust + Bevy reimplementation of the 1.12.1 client. It needs no data changes: everything is derived from -what a 1.12 install already carries (MCLQ / MLIQ liquid grids and their authored depth bytes, the +what a 1.12 install already carries (MCLQ liquid depth and MLIQ opacity bytes, their liquid grids, the `Light.dbc` water and sky rows, the opaque scene depth). It is a **module on top of the reference water, not a replacement for it**. The reference path @@ -12,7 +12,7 @@ player picks it, and every part of it can be switched off. ## Credits - **Original project: [WarcraftXL](https://github.com/WarcraftXL)** — the `wxl-experimental-water` - module for the 1.12 client. + 3.3.5a client extension. - **Author: iThorgrim.** The WarcraftXL water module is the design reference for this one, and its author has given @@ -41,6 +41,7 @@ the technique is listed in the table below. Do not remove either. | Quality tiers | `Classic` = the reference water, `Enhanced`, `High` (= Enhanced + scenery reflections) | Video options → Water Quality, cvar `waterQuality`, env `WOW_WATER=0\|1\|2` | | Procedural waves | multi-band analytic waves with exact normals; the long swell moves ocean vertices | tier | | Ocean / inland profiles | the sea and lakes/rivers have their own colour, energy and reflectivity | tier | +| WMO pools | exterior canals use a calm drifting profile with outdoor glints; interiors use their authored MOMT colour and room-fog reflection | tier | | Depth look | light fades per channel over the real path through the water; the bed is seen through it, bent by the waves; sunlit shallows carry a caustic web | tier | | Reflection and glints | Fresnel sky reflection from the zone's sky rows, sun path, moon path, point-light glints | tier | | Beach surf | a travelling wave front, a lace of foam that dissolves behind it, a swash sheet on the sand | tier | @@ -64,7 +65,8 @@ request. If it ever returns it should read the authored MCLQ flow records, not d `materials.rs` (`LiquidExt` fields 103-106, shader registration), `lib.rs` (`WaterQuality`). - **World** (`crates/benilla-world/src`): `liquid/scene_depth.rs` (copies the opaque depth AND colour after the main opaque pass), `liquid/waves.rs` (the CPU mirror of the vertex swell), `liquid/surface.rs` - (per-kind material parameters), `water_fx/bob.rs` (swimmer bob), `lighting/lava_light.rs`. + (per-kind material parameters and the opt-in `WOW_WATER_PROBE` WMO classification/depth log), + `water_fx/bob.rs` (swimmer bob), `lighting/lava_light.rs`. - **App** (`crates/benilla-app/src`): the setting in `cvars.rs` / `video.rs`, the options rows in `assets/ui/OptionsFrame.xml`, the capture scenes `water-*` and `lava-*` in `capture/scenarios.rs`. @@ -73,6 +75,8 @@ request. If it ever returns it should read the authored MCLQ flow records, not d - `Classic` must stay byte-identical: capture `water-noon` with `WOW_WATER=0` and compare with the baseline before and after any edit. - The CPU swell (`liquid/waves.rs`) and the shader's `WATER_WAVES` table are one contract. +- MLIQ's per-vertex byte is an opacity-ramp coordinate, not water depth; enhanced WMO water uses + reconstructed scene depth for its column measurement. - Every screen derivative in `enhanced_water()` is taken at the top of the function (WGSL uniformity); WGSL only fails at pipeline creation, so verify by running a `water-*` scene. - Never rotate a noise domain by a per-pixel angle at world coordinates (~1e4 yd): it shears. diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl index 667e7459..7662d00d 100644 --- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl +++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl @@ -26,7 +26,7 @@ struct WaterParams { // x = quality (0 Classic, 1 Enhanced, 2 High); y = wave energy (ocean 1.0, ADT inland 0.18, - // WMO pools 0.12); z = the pinned capture time (WOW_CAPTURE_WATER_T); w = clock enable. + // WMO exterior 0.26, WMO interior 0.08); z = pinned capture time; w = clock enable. mode: vec4, // x = which renderer (0 ADT MCLQ, 1 WMO exterior, 2 WMO interior); y = ocean; z = fullbright; // w = this surface may take the WMO INTERIOR fog block (upstream's kind.z). @@ -37,26 +37,10 @@ struct WaterParams { }; @group(#{MATERIAL_BIND_GROUP}) @binding(104) var water: WaterParams; -// The shared global light (`lighting::global_light`), rows 0-20 + the point-light table. Mirrors -// the buffer layout the terrain/model shaders read; keep it in step with theirs. -struct WaterLight { - light_ambient: vec4, // 0 - light_diffuse: vec4, // 1 - light_sun: vec4, // 2 xyz = sun TRAVEL dir (to-light = -xyz) - light_spec: vec4, // 3 - fog_color: vec4, // 4 - fog_params: vec4, // 5 - _sh: array, 6>, // 6-11 - _sh_c16: vec4, // 12 - water_river: array, 2>, // 13-14 - water_ocean: array, 2>, // 15-16 - _grade: vec4, // 17 - wmo_fog_color: vec4, // 18 - wmo_fog_params: vec4, // 19 - point_count: vec4, // 20 live count + point-light controls - points: array, 512>, // 21+ position/range, colour/lane pairs -}; -@group(#{MATERIAL_BIND_GROUP}) @binding(105) var water_light: WaterLight; +// MONKEY (water): the shared global light (`lighting::global_light`), rows 0-20 + the point-light +// table, is deliberately a row view of the exact terrain/model ABI. Bevy's composable-module +// writer rejects a second partial struct view because its unused members require substitution. +@group(#{MATERIAL_BIND_GROUP}) @binding(105) var water_light: array>; // What the fragment stage hands over: the three varyings this module reads. struct WaterFragment { @@ -64,6 +48,8 @@ struct WaterFragment { world_position: vec4, // The authored per-vertex swatch depth (UV1.x): ocean byte/255, about 148 yd at 1.0. depth: f32, + // MONKEY (water): an interior WMO pool's authored MOMT diffuse colour; white on other lanes. + colour: vec4, // Upstream's per-surface room-fog lane (MeshTag bit 30), to pick the same fog block it would. room_fog: u32, }; @@ -74,11 +60,11 @@ struct WaterFragment { // scenery come out of the scene copy, which the terrain and model shaders have already fogged // (fogging them again made shallow water foggier than the dry sand beside it). fn water_fog(world_pos: vec3, room_fog: u32) -> vec4 { - var colour = water_light.fog_color; - var span = water_light.fog_params.xy; + var colour = water_light[4]; + var span = water_light[5].xy; if water.lane.w > 0.5 && room_fog != 0u { - colour = water_light.wmo_fog_color; - span = water_light.wmo_fog_params.xy; + colour = water_light[18]; + span = water_light[19].xy; } if colour.w <= 0.5 { return vec4(colour.rgb, 1.0); } let eye_z = -(view.view_from_world * vec4(world_pos, 1.0)).z; @@ -171,10 +157,10 @@ fn water_waves(p: vec2, t: f32, distance: f32, footprint: f32, let energy = clamp(water.mode.y, 0.0, 1.0); let tempo = mix(0.4, 1.0, sqrt(energy)); let inland = water.lane.y < 0.5 || water.lane.x > 0.5; - // Inland ADT water drifts gently (a constant vector: a rigid translation, never a shear). - // WMO pools have no drift at all. + // MONKEY (water): inland ADT and exterior WMO water drift gently (a constant vector: a rigid + // translation, never a shear). True interior pools stay still. var ripple_p = p; - if inland && water.lane.x < 0.5 { + if inland && water.lane.x < 1.5 { ripple_p -= t * vec2(0.06, 0.025); } var result = vec3(0.0); @@ -196,6 +182,12 @@ fn water_waves(p: vec2, t: f32, distance: f32, footprint: f32, return result; } +// MONKEY (water): seam for the later wet-weather lane. Its result is a height-gradient +// perturbation in world XZ; zero preserves today's image until that lane supplies rain data. +fn rain_ripple_normal(world_xz: vec2, time: f32) -> vec2 { + return vec2(0.0); +} + fn foam_hash(p_in: vec2) -> f32 { // MONKEY (foam noise): world coordinates here are ~1e4 yd and are scaled further before they // arrive, so `p * 0.1031` sat near 2e3 where an f32 keeps only ~13 fraction bits - the hash @@ -464,7 +456,11 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec // 148 yd): where the bed lies well below what the pixel sees, the pixel is an OBJECT, and an // object gets the thin wet-edge line only. The byte is a FLOOR (1.72 per yd), so on a real beach the // authored bed is never deeper than the scene by more than interpolation error: allow 0.5 yd. - let bed_yd = clamp(in.depth, 0.0, 1.0) * 148.0; + // MONKEY (water): MLIQ carries an opacity-ramp byte, not ADT's authored bed-depth byte. For a + // WMO pool the only actual column measurement is the reconstructed scene depth, so never turn + // its opacity into 148 yards of fictitious water. + let bed_yd = select(vertical_depth, clamp(in.depth, 0.0, 1.0) * 148.0, + water.lane.x < 0.5); let on_bed = 1.0 - smoothstep(0.5, 1.0, bed_yd - vertical_depth); let shore_gain = select(0.0, shoal * shore_collapse * shore_offshore_fade * on_bed, ocean_mesh) * energy; @@ -477,7 +473,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec let shore = select(1.0, swell_shore_fade(in.depth), ocean_mesh); let wave = water_waves(p, t, length(eye_pos), footprint, shore, false); // One surface gradient: the procedural bands and the shore break. - let surf_grad = wave.yz + shore_grad; + let surf_grad = wave.yz + shore_grad + rain_ripple_normal(in.world_position.xz, t); var n = normalize(vec3(-surf_grad.x, 1.0, -surf_grad.y)); if dot(n, to_view) < 0.0 { n = -n; } @@ -507,14 +503,22 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec clamp(inland_row.b, inland_row.g * 0.82, inland_row.g * 1.15)); body = mix(clear_tint, inland_deep, smoothstep(0.0, 5.0, vertical_depth)); } - let to_light = -normalize(water_light.light_sun.xyz); + // MONKEY (water): the reference interior renderer carries MOMT.diffColor on the pool vertex. + // Under a roof that authored colour replaces the outdoor Light.dbc/sky-derived body palette. + if water.lane.x > 1.5 { + body = in.colour.rgb; + } + let to_light = -normalize(water_light[2].xyz); // MONKEY (water body): water is lit by the light it SCATTERS, not only by N.L on its skin, so // the lit body keeps a high floor (ambient x 1.35, N.L floor 0.5). With the old 0.25 floor a // noon lake rendered near-black navy where the owner reference is a bright teal-blue; night // still darkens because both rows do. - let lighting = clamp(water_light.light_ambient.rgb * 1.35 + water_light.light_diffuse.rgb + let lighting = clamp(water_light[0].rgb * 1.35 + water_light[1].rgb * max(dot(n, to_light), 0.5), vec3(0.0), vec3(1.0)); var rgb = body * lighting; + if water.lane.x > 1.5 { + rgb = body; + } // MONKEY (shore waves): the shore train is shown mainly as a CONTINUOUS crest brightening, not as // a normal tilt. Its direction comes from the screen-space gradient of the bed depth, and the // terrain is flat triangles, so that direction jumps at every triangle edge: at SHORE_TILT 0.30 @@ -537,9 +541,14 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec let reflected = reflect(-to_view, reflection_n); let sky_linear = mix(water.sky_horizon.rgb, water.sky_zenith.rgb, clamp(reflected.y * 1.4, 0.0, 1.0)); // Linear reflection interpolation, then conversion to the world's gamma blend/fog lane. - let sky = select(12.92 * sky_linear, + var sky = select(12.92 * sky_linear, 1.055 * pow(max(sky_linear, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055, sky_linear > vec3(0.0031308)); + // MONKEY (water): an interior pool has no sky probe. High may still replace this fallback with + // a real SSR hit, while Enhanced reflects the room's own fog colour and keeps sun glints off. + if water.lane.x > 1.5 { + sky = fog.rgb; + } var reflectivity = mix(0.06, 0.88, pow(1.0 - max(dot(n, to_view), 0.0), 4.0)); if !ocean_mesh { reflectivity = min(reflectivity, 0.55); } @@ -572,7 +581,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec let web = caustic_web(bed_world.xz + n.xz * 0.35, t) * (1.0 - smoothstep(0.12, 0.40, footprint * CAUSTIC_SCALE * 4.0)); behind *= 1.0 + web * CAUSTIC_GAIN * sun_focus * caustic_fade - * dot(water_light.light_diffuse.rgb, vec3(0.2126, 0.7152, 0.0722)); + * dot(water_light[1].rgb, vec3(0.2126, 0.7152, 0.0722)); } // Light that reaches the eye through water has been scattered on the way, so it carries the // water's own tint as well as the colour extinction leaves: without this, yellow sand under a @@ -616,7 +625,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec + 0.25 * pow(ndoth, 60.0 / sqrt(spread)) / sqrt(spread)) * smoothstep(0.0, 0.1391731, celestial_dir.y); } else { - rgb += water_light.light_diffuse.rgb + rgb += water_light[1].rgb * (pow(ndoth, 400.0 / spread) / sqrt(spread) + 0.25 * pow(ndoth, 60.0 / sqrt(spread)) / sqrt(spread)) * smoothstep(-0.02, 0.08, celestial_dir.y); @@ -626,10 +635,13 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec // Rank candidates by actual fragment distance, not table order. Only four lights are shaded. var nearest = array(256u, 256u, 256u, 256u); var distances = array(900.0, 900.0, 900.0, 900.0); - for (var i = 0u; i < min(u32(water_light.point_count.x), 256u); i += 1u) { - let colour = water_light.points[2u * i + 1u]; - if colour.w >= 0.5 && water.lane.x < 1.5 { continue; } - let delta = water_light.points[2u * i].xyz - in.world_position.xyz; + for (var i = 0u; i < min(u32(water_light[20].x), 256u); i += 1u) { + let colour = water_light[21u + 2u * i + 1u]; + // MONKEY (water): outside takes only exterior lights; a true interior pool takes only its + // room fixtures. This keeps street torches on canals and out of pools behind closed walls. + let interior_fixture = colour.w >= 0.5; + if interior_fixture != (water.lane.x > 1.5) { continue; } + let delta = water_light[21u + 2u * i].xyz - in.world_position.xyz; let d2 = dot(delta, delta); if d2 >= distances[3] { continue; } var slot = 3u; @@ -645,8 +657,8 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec } for (var j = 0u; j < 4u; j += 1u) { if nearest[j] == 256u { continue; } - let pos = water_light.points[nearest[j] * 2u]; - let colour = water_light.points[nearest[j] * 2u + 1u]; + let pos = water_light[21u + nearest[j] * 2u]; + let colour = water_light[21u + nearest[j] * 2u + 1u]; let delta = pos.xyz - in.world_position.xyz; let distance = sqrt(max(distances[j], 0.0001)); let light_dir = delta / distance; @@ -710,7 +722,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec * wall_suppression * energy; let foam = min(FOAM_MAX, contact_alpha + arcs_alpha + crest_alpha); - let illumination = water_light.light_ambient.rgb + water_light.light_diffuse.rgb; + let illumination = water_light[0].rgb + water_light[1].rgb; let foam_luma = clamp(dot(illumination, vec3(0.2126, 0.7152, 0.0722)), 0.22, 1.0); // Matte foam composites on top of reflection with its own coverage. // The body now carries its own transmission (above), so the surface is opaque bar the very diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl index 1a9d72e7..248497bf 100644 --- a/crates/benilla-assets/src/shaders/liquid.wgsl +++ b/crates/benilla-assets/src/shaders/liquid.wgsl @@ -209,7 +209,9 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4 { // Returned as-is: the module fogs its own surface terms (the scene it shows through is // already fogged). return enhanced_water( - WaterFragment(in.clip_position, in.world_position, in.depth, in.room_fog), + // MONKEY (water): the enhanced interior arm consumes the MOMT colour already carried + // by the reference fragment interface; exterior/ADT vertices supply white. + WaterFragment(in.clip_position, in.world_position, in.depth, in.vcolor, in.room_fog), shallow_enhanced, deep_enhanced); } diff --git a/crates/benilla-world/src/liquid/surface.rs b/crates/benilla-world/src/liquid/surface.rs index 31a4f939..df6edc5b 100644 --- a/crates/benilla-world/src/liquid/surface.rs +++ b/crates/benilla-world/src/liquid/surface.rs @@ -259,6 +259,36 @@ pub(crate) fn spawn_wmo_liquids<'a>( }; let path = LiquidPath::wmo(interior); for lq in liquids { + // MONKEY (water): opt-in evidence for WMO-liquid classification and the raw MLIQ opacity + // contract. The byte is not a depth; logging it beside the group and world centre makes a + // deterministic capture sufficient to identify the exact canal/pool surface in question. + if std::env::var_os("WOW_WATER_PROBE").is_some() { + let (depth_min, depth_max, depth_sum) = lq.depths.iter().fold( + (f32::INFINITY, f32::NEG_INFINITY, 0.0), + |(lo, hi, sum), &v| (lo.min(v), hi.max(v), sum + v), + ); + let local_center = lq + .positions + .iter() + .map(|&position| wow_to_bevy(position)) + .sum::() + / lq.positions.len().max(1) as f32; + let world_center = transform.transform_point(local_center); + let depth_mean = depth_sum / lq.depths.len().max(1) as f32; + info!( + "water-probe: WMO group={:?} path={path:?} kind={:?} centre_bevy=({:.2},{:.2},{:.2}) opacity_byte=min:{:.0} mean:{:.1} max:{:.0} verts={} wet_cells={}", + pool.owner.map(|room| room.group), + lq.kind, + world_center.x, + world_center.y, + world_center.z, + depth_min.clamp(0.0, 1.0) * 255.0, + depth_mean.clamp(0.0, 1.0) * 255.0, + depth_max.clamp(0.0, 1.0) * 255.0, + lq.positions.len(), + lq.wet.iter().filter(|&&wet| wet).count(), + ); + } // The one path that can scroll, decided by the nibble, not the kind (`scrolls`). let scroll = scrolls(lq.sound_nibble); // The interior arm's body colour via the pool's MLIQ `materialId`; none when fullbright. @@ -421,10 +451,19 @@ pub(super) fn setup_liquid( water: benilla_assets::WaterUniform { mode: Vec4::new( water_quality.0 as f32, - if kind.is_fullbright() { 0.0 } - else if path != LiquidPath::Adt { 0.12 } - else if kind == LiquidKind::Ocean { 1.0 } - else { 0.18 }, + if kind.is_fullbright() { + 0.0 + // MONKEY (water): exterior WMO canals need a calm but legible ripple + // profile; true interior pools stay quieter under a roof. + } else if path == LiquidPath::WmoExterior { + 0.26 + } else if path == LiquidPath::WmoInterior { + 0.08 + } else if kind == LiquidKind::Ocean { + 1.0 + } else { + 0.18 + }, capture_time, if deterministic { 0.0 } else { 1.0 }, ), diff --git a/crates/benilla-world/src/liquid/waves.rs b/crates/benilla-world/src/liquid/waves.rs index aad4d247..363a87fe 100644 --- a/crates/benilla-world/src/liquid/waves.rs +++ b/crates/benilla-world/src/liquid/waves.rs @@ -44,8 +44,8 @@ const GRAVITY_YD: f32 = 10.72; const TWO_PI: f32 = 6.2831853; /// The ADT **ocean**'s wave energy — `liquid/surface.rs` packs `water.mode.y = 1.0` for -/// `LiquidPath::Adt` + [`benilla_formats::LiquidKind::Ocean`] (river/lake 0.18, WMO pools 0.12, -/// fullbright 0.0). The vertex swell arm only ever runs on that one combination +/// `LiquidPath::Adt` + [`benilla_formats::LiquidKind::Ocean`] (river/lake 0.18, WMO exterior 0.26, +/// WMO interior 0.08, fullbright 0.0). The vertex swell arm only ever runs on that combination /// (`enhanced_water.wgsl`), so this is the only energy a bob can ever be driven at — the parameter /// stays open because the function is the shader's, not the bob's. pub const OCEAN_WAVE_ENERGY: f32 = 1.0; From 88ceb9d2c2fc283f3fff61f745aae914e5adf4a1 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 03:23:34 +0100 Subject: [PATCH 15/87] MONKEY (skybox): weighted zone skybox lane, flags, animated skybox rig CameraSkybox becomes a weighted layer list (ghost > WMO > zone, modern crossfade), flags 0x1 day cycle / 0x2 keep celestial / 0x4 fog cone, the celestial model as a second layer, and a CPU rig + mat-anim rows so skybox bones, texture transforms, colour and alpha tracks play. Co-Authored-By: Claude Opus 5.5 --- crates/benilla-world/src/lib.rs | 2 + .../benilla-world/src/model_render/batch.rs | 46 +- crates/benilla-world/src/skybox.rs | 845 ++++++++++++++---- crates/benilla-world/src/skybox_anim.rs | 527 +++++++++++ crates/benilla-world/src/world_census.rs | 5 +- 5 files changed, 1260 insertions(+), 165 deletions(-) create mode 100644 crates/benilla-world/src/skybox_anim.rs diff --git a/crates/benilla-world/src/lib.rs b/crates/benilla-world/src/lib.rs index 02aef8e8..38281004 100644 --- a/crates/benilla-world/src/lib.rs +++ b/crates/benilla-world/src/lib.rs @@ -146,6 +146,8 @@ mod shaders; pub mod sky; pub mod sky_order; pub mod skybox; +// MONKEY (skybox): the skybox rig and material loops. +mod skybox_anim; pub mod static_gx; pub mod static_merge; pub mod straddle; diff --git a/crates/benilla-world/src/model_render/batch.rs b/crates/benilla-world/src/model_render/batch.rs index 9adc718e..eeeded74 100644 --- a/crates/benilla-world/src/model_render/batch.rs +++ b/crates/benilla-world/src/model_render/batch.rs @@ -110,11 +110,16 @@ impl M2BatchMaterials<'_> { /// (`[CM2Model+0x180]`, from the interior crossfade `[0xce9bdc]`) scales every batch's alpha, /// and `0 < A < 1` promotes a batch to SRC_ALPHA blending (`0x70c190`): `fade_blend` is that /// twin, or `steady` when the batch already blends. + /// + /// MONKEY (skybox): `uv` and `tint` are the batch's texture-transform and colour loops, the + /// material's seed and identity; [`crate::skybox_anim`] drives their rows. pub fn skybox( &mut self, sub: &benilla_formats::RenderSubmesh, texture: Option>, order: u16, + uv: Option<&std::sync::Arc>, + tint: Option<&std::sync::Arc>, ) -> Option { let light = self.light.as_ref()?.0.clone(); let torch = self.torch.binds()?; @@ -136,10 +141,9 @@ impl M2BatchMaterials<'_> { sub.env_map, ShadeSel::Lit, // unread: every shipped skybox batch is UNLIT (0x01) order, - // No texture-transform or colour loop: neither shipped skybox authors one. Wiring - // one needs the `M2Model` lane's `Arc`, which the material key identifies it by. - None, - None, + // MONKEY (skybox): the loops' `Arc`s, which the material key identifies them by. + uv, + tint, None, // M2: no MOBA class, no SIDN, no WINDOW None, false, @@ -161,6 +165,40 @@ impl M2BatchMaterials<'_> { Some(SkyboxBatch { steady, fade_blend }) } + /// MONKEY (skybox): the flag `0x4` fog cone's material: an untextured, unlit, two-sided blend + /// on the sky lane, its colour the vertex colour times the tint row the skybox lane writes. + pub fn sky_fog_cone(&mut self, order: u16) -> Option> { + let light = self.light.as_ref()?.0.clone(); + let torch = self.torch.binds()?; + Some(model_material( + &mut self.cache.0, + &mut self.materials, + None, + ModelBlend::Blend, + true, + false, + false, + true, // unlit + false, + false, + true, // never writes depth + false, // …and always tests it + benilla_formats::FogPolicy::Off, + false, + ShadeSel::Lit, + order, + None, + None, + None, + None, + false, + true, // the sky lane + &light, + &torch, + None, + )) + } + /// One steady material lit by a render target's own light buffer (a portrait booth, the model /// pane, char-select), which [`super::MatKey::light`] keys apart from the world. `rig` lights /// by the scene's authored M2 rig ([`ShadeSel::Rig`]) and plays the UV animation. `order` must diff --git a/crates/benilla-world/src/skybox.rs b/crates/benilla-world/src/skybox.rs index d4995c09..c7ab7add 100644 --- a/crates/benilla-world/src/skybox.rs +++ b/crates/benilla-world/src/skybox.rs @@ -14,31 +14,88 @@ //! both moons, gradient band and cloud dome; below it they draw under the sky. The glare quads //! draw on their own path (`0x483740` → `0x6d48c0` → `0x7e57e0`), and the fog, ambient and //! diffuse are untouched. +//! +//! MONKEY (skybox): a third slot, the living player's zone skybox, behind the cvar +//! `zoneSkyboxes` ([`ZoneSkyboxes`]). The 1.12 engine never draws a `LightParams` skybox for the +//! living; the modern client does, weighted by the Light sphere falloff +//! ([`benilla_formats::LightCatalog::zone_skyboxes`]). The backdrop is a weighted list +//! ([`CameraSkybox`]): ghost > WMO > zone, the WMO sky crossfading the zone list by the modern +//! collector's rule. `LightSkybox.dbc` flags from the extended table: `0x1` plays sequence 0 over +//! the game day, `0x2` keeps the celestial pass, `0x4` draws a fog-colour cone over the horizon; +//! a celestial model draws as a layer under its main model. Every batch animates +//! ([`crate::skybox_anim`]): bones, texture transforms, colour and alpha tracks. -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; use bevy::asset::RenderAssetUsages; +use bevy::camera::visibility::NoFrustumCulling; +use bevy::math::Affine3A; use bevy::mesh::{Indices, MeshTag, PrimitiveTopology}; use bevy::prelude::*; use crate::model_render::M2BatchMaterials; +use crate::skybox_anim::{SkyMatLane, SkyRig}; use crate::view::WorldCamera; use benilla_assets::coords::{bevy_to_wow, wow_to_bevy}; use benilla_assets::materials::WowModelMaterial; use benilla_assets::WmoModel; use benilla_assets::{LockRecover, WorldAssets}; +use benilla_formats::{SKYBOX_FOG_BLEND, SKYBOX_FULL_DAY, SKYBOX_KEEP_CELESTIAL}; /// The MOGP/MOGI group flag asking for the root's MOSB sky; the loader keeps the MOGP copy in /// `WmoGroupNav::flags`. const SHOW_SKYBOX: u32 = 0x40000; -/// The skybox model this frame asks for; `None` leaves the [`crate::sky`] gradient dome. -#[derive(Resource, Default, PartialEq, Eq)] -pub struct CameraSkybox(pub Option); +/// MONKEY (skybox): batch-order base of a main model's batches; a celestial layer takes `1..`, so +/// it draws first, under the main model, and the fog cone takes [`FOG_CONE_ORDER`], last. +const MAIN_ORDER_BASE: u16 = 24; +/// MONKEY (skybox): the fog cone's batch order, the band's last distinct step. +const FOG_CONE_ORDER: u16 = 56; + +/// MONKEY (skybox): cvar `zoneSkyboxes` (0/1, default 0): draw the living player's zone skybox +/// from `LightParams`. Off leaves the reference's two slots only. +#[derive(Resource, Default, Clone, Copy, PartialEq, Eq, Debug)] +pub struct ZoneSkyboxes(pub bool); + +/// One skybox model the frame asks for, at its weight. +#[derive(Clone, Debug, PartialEq)] +pub struct SkyboxLayer { + /// The model path, as the slot named it. + pub path: String, + /// The slot weight, `[0, 1]`; 0 fills the slot without drawing (`0x6d4afe`). + pub weight: f32, + /// `LightSkybox.dbc` flags, 0 without a row or in the 1.12 table. + pub flags: u32, + /// The celestial layer of another layer's row, drawn under it. + pub celestial: bool, +} + +/// The skybox models this frame asks for; empty leaves the [`crate::sky`] gradient dome. +#[derive(Resource, Default, PartialEq)] +pub struct CameraSkybox(pub Vec); + +impl CameraSkybox { + /// The heaviest main layer, the one a readout names. + pub fn primary(&self) -> Option<&SkyboxLayer> { + self.0 + .iter() + .filter(|l| !l.celestial) + .fold(None, |best: Option<&SkyboxLayer>, l| match best { + Some(b) if b.weight >= l.weight => Some(b), + _ => Some(l), + }) + } + + fn layer(&self, path: &str) -> Option<&SkyboxLayer> { + self.0.iter().find(|l| l.path == path) + } +} /// The skybox slot's weight this frame: the interior crossfade [`crate::lighting::WmoCrossfade`] /// (±0.25/s, `[0x8115b0]`) for a WMO sky, 1.0 for the ghost sky (`0x6d2260`), and 0 when none -/// resolves. +/// resolves. MONKEY (skybox): the heaviest layer that does not keep the celestial pass (flag +/// `0x2`), so a combining zone sky never stands the procedural sky down. #[derive(Resource, Default, PartialEq)] pub struct SkyboxWeight(pub f32); @@ -60,22 +117,55 @@ struct SkyboxPart { /// The blend-promotion twin for `0 < weight < 1` (`0x811fe0`); `steady` itself when the batch /// already blends. fade_blend: Handle, + /// MONKEY (skybox): the batch's colour-alpha × transparency loops (`0x707680`). + alpha: Option, + /// MONKEY (skybox): the last sampled alpha factor. + anim_alpha: f32, + /// MONKEY (skybox): the batch's texture-transform and colour rows. + lane: SkyMatLane, + /// MONKEY (skybox): how the rig moves the batch. + pose: PartPose, } -/// A batch wholly weighted to one parentless rotation-only bone, so it spins rigidly about the -/// bone's pivot ([`benilla_formats::BoneSpin`]) with no joint children, which would lag the -/// post-propagation anchor a frame. Only `CavernsOfTimeSky.m2`'s four asteroid belts spin. -#[derive(Component)] -struct SkyboxSpin { - /// The bone's pivot, in the same (Bevy) space the batch's vertices were baked into. - pivot: Vec3, - spin: benilla_formats::BoneSpin, +/// MONKEY (skybox): how a batch follows its model's rig. +enum PartPose { + /// No moving bone under the batch. + Static, + /// Wholly weighted to one bone: the bone's matrix is the batch's transform. + Rigid(u16), + /// Spread over moving bones: skinned on the CPU into its mesh. + Skinned { + mesh: Handle, + base: Vec, + joints: Vec<[u16; 4]>, + weights: Vec<[f32; 4]>, + }, } +/// MONKEY (skybox): the batch's model-space pose this frame, composed with the eye in +/// [`follow_camera`]. +#[derive(Component, Default)] +struct SkyboxLocal(Affine3A); + /// Skybox paths built this session, failed loads included, so a failure is not retried per frame. #[derive(Resource, Default)] struct BuiltSkyboxes(HashSet); +/// MONKEY (skybox): each built model's rig, by path. +#[derive(Resource, Default)] +struct SkyRigs(HashMap>); + +/// MONKEY (skybox): the flag `0x4` fog cone: its entity and its tint row. +#[derive(Resource, Default)] +struct FogCone { + entity: Option, + slot: Option, +} + +/// MONKEY (skybox): marks the fog cone's entity. +#[derive(Component)] +struct FogConePart; + /// [`CameraSkybox`] is settled after this set; the dome's gate runs after it, so the two backdrops /// agree within a frame. #[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -89,9 +179,17 @@ impl Plugin for SkyboxPlugin { app.init_resource::() .init_resource::() .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() .add_systems( Update, - (resolve_camera_skybox, build_skybox, apply_skybox_visibility) + ( + resolve_camera_skybox, + build_skybox, + animate_skyboxes, + apply_skybox_visibility, + ) .chain() // After the PVS pass, whose flood this reads the same frame. .after(crate::wmo_portal::WmoPvsSet) @@ -108,9 +206,49 @@ impl Plugin for SkyboxPlugin { } } +/// MONKEY (skybox): a path as `model_path` spells it, for comparing a MOSB name with a DBC one. +fn norm(path: &str) -> String { + let lower = path.to_ascii_lowercase(); + match lower + .strip_suffix(".mdx") + .or_else(|| lower.strip_suffix(".mdl")) + { + Some(stem) => format!("{stem}.m2"), + None => lower, + } +} + +/// MONKEY (skybox): the modern collector's step on layers (`addSkyBox`): the same model keeps the +/// larger weight and every other layer is scaled by `1 − weight`. +fn collect_layer(layers: &mut Vec, path: &str, weight: f32, flags: u32) { + let key = norm(path); + let at = match layers.iter().position(|l| norm(&l.path) == key) { + Some(i) => { + layers[i].weight = layers[i].weight.max(weight); + i + } + None => { + layers.push(SkyboxLayer { + path: path.to_owned(), + weight, + flags, + celestial: false, + }); + layers.len() - 1 + } + }; + let w = layers[at].weight; + for (i, l) in layers.iter_mut().enumerate() { + if i != at { + l.weight *= 1.0 - w; + } + } +} + /// Resolve the wanted skybox and its weight. A WMO sky needs a group of the placement's flood PVS, /// not the camera's own group, to carry [`SHOW_SKYBOX`] (`0x6b42e0`, `ebx` the group visited) and /// its root to name a MOSB; the weight rides the down-ray claim's crossfade, a separate resolver. +#[allow(clippy::too_many_arguments)] fn resolve_camera_skybox( instances: Query<&crate::wmo_portal::WmoPortalInstance>, wmos: Res>, @@ -119,71 +257,158 @@ fn resolve_camera_skybox( current_map: Option>, cam: Query<&GlobalTransform, With>, crossfade: Res, + zone: Res, + weather: Option>, + eye_liquid: crate::liquid::EyeLiquid, mut want: ResMut, mut weight: ResMut, ) { + let pos = cam.single().ok().map(|t| bevy_to_wow(t.translation())); + let map = current_map.as_ref().map_or(0, |m| m.0); + let catalog = sampler.as_ref().map(|s| &s.0); + let flags_of = |path: &str| { + catalog + .and_then(|c| c.skybox_def_by_path(&norm(path))) + .map_or(0, |d| d.flags) + }; + let mut layers: Vec = Vec::new(); + // The ghost sky first, as its slot skips the WMO one. Resolved from the atmosphere's map and // camera position, off the same ghost flag (`PLAYER_FLAGS` 0x10), so the two switch together. - if viewer.ghost { - let ghost_sky = sampler.as_ref().and_then(|s| { - let pos = bevy_to_wow(cam.single().ok()?.translation()); - let map = current_map.as_ref().map_or(0, |m| m.0); - s.0.ghost_skybox(map, pos) + let ghost_sky = viewer + .ghost + .then(|| catalog.zip(pos).and_then(|(c, p)| c.ghost_skybox(map, p))) + .flatten(); + if let Some(sky) = ghost_sky { + // The DBC slot's weight is 1.0 whenever filled (`0x6d26cb`/`0x6d26d0`): it pops in. + layers.push(SkyboxLayer { + path: sky.to_owned(), + weight: 1.0, + flags: flags_of(sky), + celestial: false, }); - if let Some(sky) = ghost_sky { - if want.0.as_deref() != Some(sky) { - want.0 = Some(sky.to_owned()); + } else { + // MONKEY (skybox): the zone list, under the WMO slot. Hidden submerged, as the celestial + // pass is; a storm lerps the storm slot's list over the clear one, as the atmosphere does. + if zone.0 && !eye_liquid.submersion().any() { + if let (Some(c), Some(p)) = (catalog, pos) { + let storm = weather + .as_ref() + .map_or(0.0, |w| crate::weather::storm_blend(w.sky_density)); + let mut acc: Vec<(u32, f32)> = c + .zone_skyboxes(map, p, false, benilla_formats::Submersion::Dry) + .into_iter() + .map(|e| (e.id, e.weight * (1.0 - storm))) + .collect(); + if storm > 0.0 { + for e in c.zone_skyboxes(map, p, true, benilla_formats::Submersion::Dry) { + match acc.iter_mut().find(|a| a.0 == e.id) { + Some(a) => a.1 += e.weight * storm, + None => acc.push((e.id, e.weight * storm)), + } + } + } + for (id, w) in acc { + let Some(def) = c.skybox_def(id) else { + continue; + }; + if w <= 0.0 { + continue; + } + match layers.iter_mut().find(|l| norm(&l.path) == def.path) { + Some(l) => l.weight = l.weight.max(w), + None => layers.push(SkyboxLayer { + path: def.path.clone(), + weight: w, + flags: def.flags, + celestial: false, + }), + } + } + } + } + // `min()`, not the first match: query order is unstable across frames, and two + // overlapping Caverns of Time shells both qualify. + let resolved = instances + .iter() + .filter_map(|inst| { + let model = wmos.get(&inst.handle)?; + // The MOSB test first: 810 of the game's 815 WMO roots name no skybox. + let sky = model.skybox.as_deref()?; + model + .group_nav + .iter() + .enumerate() + .any(|(i, nav)| { + // Fail closed, unlike the cull: a lookup miss here would paint a sky over + // the whole world. + nav.flags & SHOW_SKYBOX != 0 + && inst.visible.get(i).copied().unwrap_or(false) + }) + .then(|| sky.to_owned()) + }) + .min(); + // The WMO slot's weight is the interior crossfade `[0xce9bdc]`; a name seen through a + // doorway at weight 0 fills the slot, which `0x6d4afe` declines to draw. + if let Some(sky) = resolved { + let t = crossfade.t(); + let flags = flags_of(&sky); + if t > 0.0 { + collect_layer(&mut layers, &sky, t, flags); + } else if !layers.iter().any(|l| norm(&l.path) == norm(&sky)) { + layers.push(SkyboxLayer { + path: sky, + weight: 0.0, + flags, + celestial: false, + }); } - // The DBC slot's weight is 1.0 whenever filled (`0x6d26cb`/`0x6d26d0`): it pops in. - weight.set_if_neq(SkyboxWeight(1.0)); - return; } } - // `min()`, not the first match: query order is unstable across frames, and two overlapping - // Caverns of Time shells both qualify. - let resolved = instances + // MONKEY (skybox): each row's celestial model, a layer at its main model's weight and flags. + let celestial: Vec = layers .iter() - .filter_map(|inst| { - let model = wmos.get(&inst.handle)?; - // The MOSB test first: 810 of the game's 815 WMO roots name no skybox. - let sky = model.skybox.as_deref()?; - model - .group_nav - .iter() - .enumerate() - .any(|(i, nav)| { - // Fail closed, unlike the cull: a lookup miss here would paint a sky over the - // whole world. - nav.flags & SHOW_SKYBOX != 0 && inst.visible.get(i).copied().unwrap_or(false) - }) - .then(|| sky.to_owned()) + .filter_map(|l| { + let path = catalog? + .skybox_def_by_path(&norm(&l.path))? + .celestial + .clone()?; + Some(SkyboxLayer { + path, + weight: l.weight, + flags: l.flags, + celestial: true, + }) }) - .min(); - if want.0 != resolved { - want.0 = resolved; + .collect(); + layers.extend(celestial); + if want.0 != layers { + want.0 = layers; } - // The WMO slot's weight is the interior crossfade `[0xce9bdc]`; a name seen through a doorway - // at weight 0 fills the slot, which `0x6d4afe` declines to draw. - weight.set_if_neq(SkyboxWeight(match want.0 { - Some(_) => crossfade.t(), - None => 0.0, - })); + let stand_down = want + .0 + .iter() + .filter(|l| l.flags & SKYBOX_KEEP_CELESTIAL == 0) + .map(|l| l.weight) + .fold(0.0, f32::max); + weight.set_if_neq(SkyboxWeight(stand_down)); } -/// Build the wanted skybox on first request; the models are small and few, so it stays built. +/// Build the wanted skyboxes on first request; the models are small and few, so they stay built. +#[allow(clippy::too_many_arguments)] fn build_skybox( mut commands: Commands, want: Res, mut built: ResMut, + mut rigs: ResMut, + mut cone: ResMut, world_assets: Option>, mut meshes: ResMut>, mut images: ResMut>, + mut table: ResMut, mut mats: M2BatchMaterials, ) { - let Some(path) = want.0.as_deref() else { - return; - }; - if built.0.contains(path) { + if want.0.is_empty() { return; } // Before the shared light buffer exists: retry, without latching `built`. @@ -193,84 +418,213 @@ fn build_skybox( let Some(mut world_assets) = world_assets else { return; // assetless run: the gradient dome stays the backdrop }; - // The model's rigid spins by bone; none under a deterministic run, which keeps bind poses. - let spins = if crate::dev_state::deterministic_run() { - Default::default() - } else { - benilla_formats::load_m2_bone_spins(&mut world_assets.chain.lock_recover(), path) - .unwrap_or_default() - }; - let subs = benilla_formats::load_m2_mesh(&mut world_assets.chain.lock_recover(), path); - let subs = match subs { - Ok(subs) if !subs.is_empty() => subs, - Ok(_) => { - warn!("skybox '{path}' has no render batches — keeping the gradient dome"); - built.0.insert(path.to_string()); - return; + if cone.entity.is_none() && want.0.iter().any(|l| l.flags & SKYBOX_FOG_BLEND != 0) { + build_fog_cone(&mut commands, &mut cone, &mut meshes, &mut table, &mut mats); + } + for layer in &want.0 { + let path = layer.path.as_str(); + if built.0.contains(path) { + continue; } - Err(e) => { - warn!("skybox '{path}' failed to load, keeping the gradient dome: {e:#}"); - built.0.insert(path.to_string()); - return; - } - }; - for (i, sub) in subs.iter().enumerate() { - let mut mesh = Mesh::new( - PrimitiveTopology::TriangleList, - RenderAssetUsages::default(), - ); - let positions: Vec<[f32; 3]> = sub - .positions - .iter() - .map(|p| wow_to_bevy(*p).to_array()) - .collect(); - mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); - // Unread by the unlit sky, but the shared shader's vertex layout needs it. - mesh.insert_attribute( - Mesh::ATTRIBUTE_NORMAL, - sub.normals - .iter() - .map(|n| wow_to_bevy(*n).to_array()) - .collect::>(), + // MONKEY (skybox): the rig, off the same bytes the batches come from. + let bytes = world_assets + .chain + .lock_recover() + .read_file(&norm(path)) + .ok(); + let rig = Arc::new( + bytes + .as_deref() + .map(SkyRig::from_bytes) + .unwrap_or_default(), ); - mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, sub.uvs.clone()); - mesh.insert_indices(Indices::U32(sub.indices.clone())); - // The batch's authored address mode: Caverns of Time's belts wrap their UVs. - let texture = sub - .texture - .as_deref() - .and_then(|t| world_assets.texture(t, (sub.wrap_x, sub.wrap_y), &mut images)); - // `i + 1`, the authored batch order (0 is unordered): every batch shares one sort distance. - let Some(pair) = mats.skybox(sub, texture, u16::try_from(i + 1).unwrap_or(0)) else { - return; // light buffer vanished mid-build; `built` is unlatched, so we retry + let subs = benilla_formats::load_m2_mesh(&mut world_assets.chain.lock_recover(), path); + let subs = match subs { + Ok(subs) if !subs.is_empty() => subs, + Ok(_) => { + warn!("skybox '{path}' has no render batches — keeping the gradient dome"); + built.0.insert(path.to_string()); + continue; + } + Err(e) => { + warn!("skybox '{path}' failed to load, keeping the gradient dome: {e:#}"); + built.0.insert(path.to_string()); + continue; + } }; - let part = commands - .spawn(( - Mesh3d(meshes.add(mesh)), - MeshMaterial3d(pair.steady.clone()), - Transform::default(), - Visibility::Hidden, // `apply_skybox_visibility` turns on exactly the wanted one - // The crossfade's alpha (bits 0..=5), written only by `apply_skybox_visibility`. - MeshTag(crate::mesh_tag::spawn_tag(0, 1.0)), - SkyboxPart { - path: path.to_string(), - steady: pair.steady, - fade_blend: pair.fade_blend, + let base = if layer.celestial { 0 } else { MAIN_ORDER_BASE }; + for (i, sub) in subs.iter().enumerate() { + let mut mesh = Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::default(), + ); + let positions: Vec = sub.positions.iter().map(|p| wow_to_bevy(*p)).collect(); + mesh.insert_attribute( + Mesh::ATTRIBUTE_POSITION, + positions.iter().map(|p| p.to_array()).collect::>(), + ); + // Unread by the unlit sky, but the shared shader's vertex layout needs it. + mesh.insert_attribute( + Mesh::ATTRIBUTE_NORMAL, + sub.normals + .iter() + .map(|n| wow_to_bevy(*n).to_array()) + .collect::>(), + ); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, sub.uvs.clone()); + // MONKEY (skybox): the constant M2Color, when the batch has one that is not white. + if sub.vertex_colors.len() == sub.positions.len() + && sub + .vertex_colors + .iter() + .any(|c| c.iter().any(|v| (v - 1.0).abs() > 1e-3)) + { + mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, sub.vertex_colors.clone()); + } + mesh.insert_indices(Indices::U32(sub.indices.clone())); + // The batch's authored address mode: Caverns of Time's belts wrap their UVs. + let texture = sub + .texture + .as_deref() + .and_then(|t| world_assets.texture(t, (sub.wrap_x, sub.wrap_y), &mut images)); + // MONKEY (skybox): the texture transform and colour loops as material identities. + let uv = sub.uv_anim.clone().map(Arc::new); + let tint = sub.rgb_anim.clone().map(Arc::new); + // The authored batch order after the layer's base (0 is unordered): every batch shares + // one sort distance. + let order = base + u16::try_from(i + 1).unwrap_or(0); + let Some(pair) = mats.skybox(sub, texture, order, uv.as_ref(), tint.as_ref()) else { + return; // light buffer vanished mid-build; `built` is unlatched, so we retry + }; + let lane = SkyMatLane::register( + sub, + uv, + tint, + &mut table, + mats.materials(), + &[pair.steady.id(), pair.fade_blend.id()], + ); + let mesh = meshes.add(mesh); + let pose = match sole_bone(sub) { + Some(b) if rig.bone_moves(b) => PartPose::Rigid(b), + Some(_) => PartPose::Static, + None if rig.animates() && !sub.joints.is_empty() => PartPose::Skinned { + mesh: mesh.clone(), + base: positions, + joints: sub.joints.clone(), + weights: sub.weights.clone(), }, - )) - .id(); - if let Some(spin) = sole_bone(sub).and_then(|b| spins.get(&b)) { - commands.entity(part).insert(SkyboxSpin { - pivot: wow_to_bevy(spin.pivot), - spin: spin.clone(), - }); + None => PartPose::Static, + }; + let skinned = matches!(pose, PartPose::Skinned { .. }); + let part = commands + .spawn(( + Mesh3d(mesh), + MeshMaterial3d(pair.steady.clone()), + Transform::default(), + Visibility::Hidden, // `apply_skybox_visibility` turns on exactly the wanted one + // The crossfade's alpha (bits 0..=5), written only by `apply_skybox_visibility`. + MeshTag(crate::mesh_tag::spawn_tag(0, 1.0)), + SkyboxLocal::default(), + SkyboxPart { + path: path.to_string(), + steady: pair.steady, + fade_blend: pair.fade_blend, + alpha: sub.alpha_anim.clone(), + anim_alpha: 1.0, + lane, + pose, + }, + )) + .id(); + if skinned { + // The mesh moves under its bounds; the shell surrounds the eye anyway. + commands.entity(part).insert(NoFrustumCulling); + } } + rigs.0.insert(path.to_string(), rig); + built.0.insert(path.to_string()); } - built.0.insert(path.to_string()); } -/// The bone every vertex of this batch is wholly weighted to, if any: the batch's half of -/// [`benilla_formats::BoneSpin`]'s rigid condition. +/// MONKEY (skybox): the flag `0x4` cone, the modern client's sky-cone draw in the final fog colour +/// (`skyMesh0x4Sky`): a band around the eye, opaque at and below the horizon, fading out by 24° +/// of elevation. White vertices carry the alpha; the fog colour rides its tint row. +fn build_fog_cone( + commands: &mut Commands, + cone: &mut FogCone, + meshes: &mut Assets, + table: &mut crate::mat_anim_table::MatAnimTable, + mats: &mut M2BatchMaterials, +) { + const SEGMENTS: usize = 32; + const RADIUS: f32 = 40.0; + // (elevation degrees, alpha), bottom to top. + const RINGS: [(f32, f32); 7] = [ + (-60.0, 1.0), + (0.0, 1.0), + (4.0, 0.85), + (8.0, 0.6), + (12.0, 0.35), + (18.0, 0.12), + (24.0, 0.0), + ]; + let mut positions = Vec::new(); + let mut colors = Vec::new(); + for (elev, alpha) in RINGS { + let (se, ce) = elev.to_radians().sin_cos(); + for s in 0..=SEGMENTS { + let az = s as f32 / SEGMENTS as f32 * std::f32::consts::TAU; + positions.push([RADIUS * ce * az.cos(), RADIUS * se, RADIUS * ce * az.sin()]); + colors.push([1.0, 1.0, 1.0, alpha]); + } + } + let row = SEGMENTS as u32 + 1; + let mut indices = Vec::new(); + for r in 0..(RINGS.len() as u32 - 1) { + for s in 0..SEGMENTS as u32 { + let a = r * row + s; + let b = a + row; + indices.extend_from_slice(&[a, b, a + 1, a + 1, b, b + 1]); + } + } + let n = positions.len(); + let mut mesh = Mesh::new( + PrimitiveTopology::TriangleList, + RenderAssetUsages::default(), + ); + mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); + mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, vec![[0.0, 1.0, 0.0]; n]); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, vec![[0.0, 0.0]; n]); + mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors); + mesh.insert_indices(Indices::U32(indices)); + let Some(material) = mats.sky_fog_cone(FOG_CONE_ORDER) else { + return; + }; + let slot = table.alloc(); + if let Some(slot) = slot { + crate::model_render::lazy::with_material_mut(mats.materials(), material.id(), |m| { + m.extension.anim_slots.y = f32::from(slot); + }); + } + let entity = commands + .spawn(( + Mesh3d(meshes.add(mesh)), + MeshMaterial3d(material), + Transform::default(), + Visibility::Hidden, + MeshTag(crate::mesh_tag::spawn_tag(0, 1.0)), + SkyboxLocal::default(), + FogConePart, + NoFrustumCulling, + )) + .id(); + cone.entity = Some(entity); + cone.slot = slot; +} + +/// The bone every vertex of this batch is wholly weighted to, if any: a rigid batch takes the +/// bone's matrix whole, with no skinning. fn sole_bone(sub: &benilla_formats::RenderSubmesh) -> Option { let bone = sub.joints.first()?[0]; (sub.weights.len() == sub.joints.len() @@ -283,22 +637,145 @@ fn sole_bone(sub: &benilla_formats::RenderSubmesh) -> Option { .then_some(bone) } -/// Show the wanted skybox at the slot weight and hide every other, the sole `Visibility`, material -/// and `MeshTag` writer for these entities. As the reference: hidden at weight 0 (`0x6d4afe`), the -/// weight in every batch's alpha (`0x710cb0` → `[CM2Model+0x180]`), and below 1 the batch -/// promoted to SRC_ALPHA blending whatever its mode (`0x811fe0`). +/// MONKEY (skybox): one model's clock and pose this frame. +struct ModelClock { + band_t: f32, + gseq: f64, + live: bool, + pose: Vec, +} + +/// MONKEY (skybox): pose every shown skybox and run its material loops on the model's clock: +/// sequence 0 at `duration × day fraction` under flag `0x1`, else the scene clock. A capture keeps +/// the bind pose and the seeds, as the shared lanes freeze, unless the day drives the clock. +#[allow(clippy::too_many_arguments)] +fn animate_skyboxes( + time: Res - + + + + OptionsRow_OnLoad(self, "zoneSkyboxes", + BENILLA_ADVGFX.zoneSkyboxes, + "BENILLA_TOOLTIP_ZONE_SKYBOXES") + + + + + + + OptionsRow_OnLoad(self, "characterShadows", BENILLA_ADVGFX.characterShadows, diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index d970a9fd..07631c96 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -1378,6 +1378,72 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ look: LAVA_RIVER_LOOK, minute: 0, ui: None, + }, // MONKEY (skybox): zone skyboxes (run with `WOW_ZONE_SKYBOXES=1`) and the two stock skybox + // lanes. Karazahn40 (Turtle map 814) is the one stock sphere naming a clear-slot skybox + // (Light 553, HellfireSkyBox); the zone shots need the sky patch. + Scenario { + name: "skybox-karazhan-noon", + map: Some(814), + eye: SKYBOX_KZ_EYE, + look: SKYBOX_KZ_LOOK, + minute: 720, + ui: None, + }, + Scenario { + name: "skybox-karazhan-night", + map: Some(814), + eye: SKYBOX_KZ_EYE, + look: SKYBOX_KZ_LOOK, + minute: 0, + ui: None, + }, + Scenario { + name: "skybox-steppes-noon", + map: Some(MAP_AZEROTH), + eye: SKYBOX_STEPPES_EYE, + look: SKYBOX_STEPPES_LOOK, + minute: 720, + ui: None, + }, + Scenario { + name: "skybox-steppes-night", + map: Some(MAP_AZEROTH), + eye: SKYBOX_STEPPES_EYE, + look: SKYBOX_STEPPES_LOOK, + minute: 0, + ui: None, + }, + Scenario { + name: "skybox-blasted-noon", + map: Some(MAP_AZEROTH), + eye: SKYBOX_BLASTED_EYE, + look: SKYBOX_BLASTED_LOOK, + minute: 720, + ui: None, + }, + Scenario { + name: "skybox-blasted-night", + map: Some(MAP_AZEROTH), + eye: SKYBOX_BLASTED_EYE, + look: SKYBOX_BLASTED_LOOK, + minute: 0, + ui: None, + }, + Scenario { + name: "skybox-hyjal-noon", + map: Some(MAP_KALIMDOR), + eye: SKYBOX_HYJAL_EYE, + look: SKYBOX_HYJAL_LOOK, + minute: 720, + ui: None, + }, + Scenario { + name: "skybox-hyjal-night", + map: Some(MAP_KALIMDOR), + eye: SKYBOX_HYJAL_EYE, + look: SKYBOX_HYJAL_LOOK, + minute: 0, + ui: None, }, ]; @@ -1482,6 +1548,17 @@ pub(crate) fn ui_opted_in() -> bool { std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1") || scenario_declares_ui() } +// MONKEY (skybox): the skybox shots, eye just above the ground at a sphere's centre, pitched up so +// the upper frame is sky. +const SKYBOX_KZ_EYE: [f32; 3] = [-6474.0, -2912.0, 40.0]; +const SKYBOX_KZ_LOOK: [f32; 3] = [-6300.0, -2800.0, 95.0]; +const SKYBOX_STEPPES_EYE: [f32; 3] = [-7979.0, -2571.0, 260.0]; +const SKYBOX_STEPPES_LOOK: [f32; 3] = [-7800.0, -2450.0, 320.0]; +const SKYBOX_BLASTED_EYE: [f32; 3] = [-11300.0, -3073.0, 30.0]; +const SKYBOX_BLASTED_LOOK: [f32; 3] = [-11120.0, -2960.0, 90.0]; +const SKYBOX_HYJAL_EYE: [f32; 3] = [4637.0, -4461.0, 1060.0]; +const SKYBOX_HYJAL_LOOK: [f32; 3] = [4800.0, -4350.0, 1120.0]; + #[cfg(test)] mod ui_opt_in_tests { use super::*; diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 31a7eced..90e2fafb 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -921,6 +921,12 @@ pub(crate) const REGISTERED: &[Registered] = &[ ), // MONKEY (volumetric fog): saved live tier; capture override stays session-only. ours("volumetricFog", "1", "benilla's own: near-field volumetric fog, 0 Off / 1 Low / 2 High"), + // MONKEY (skybox): the living player's zone skybox from `LightParams`; High sets 1. + ours( + "zoneSkyboxes", + "0", + "benilla's own: draw the zone skybox LightParams names for the living, 0 Off / 1 On; the reference draws a DBC skybox only for the ghost", + ), ours( "waterQuality", "1", diff --git a/crates/benilla-app/src/debug_panel/world.rs b/crates/benilla-app/src/debug_panel/world.rs index b7d26a01..c673c2d5 100644 --- a/crates/benilla-app/src/debug_panel/world.rs +++ b/crates/benilla-app/src/debug_panel/world.rs @@ -105,12 +105,14 @@ pub(super) fn world_section(ui: &mut egui::Ui, world: &mut WorldReadout) { // 4-second crossfade (== the interior-fog blend, one number): `w 0.00` beside a name means // the flood published a skybox the crossfade hasn't engaged — faithful standing outside // the gate, not a resolve bug. - match world.skybox.0.as_deref() { - Some(path) => { + // MONKEY (skybox): the heaviest layer of the weighted list, at its own weight. + match world.skybox.primary() { + Some(layer) => { + let path = layer.path.as_str(); let leaf = path.rsplit('\\').next().unwrap_or(path); line.push_str(&format!( - " · skybox {leaf} w {:.2}", - world.skybox_weight.0 + " · skybox {leaf} w {:.2} (stand-down {:.2})", + layer.weight, world.skybox_weight.0 )); } None => line.push_str(" · sky gradient"), diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index adda66ec..db88690d 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -245,6 +245,8 @@ impl PluginGroup for GamePlugins { .add(crate::dynamic_interior::DynamicInteriorPlugin) // MONKEY (volumetric fog): bridge the live setting to Bevy. .add(crate::volumetric_fog::VolumetricFogPlugin) + // MONKEY (skybox): bridge the zone-skybox setting to the world lane. + .add(crate::zone_skybox::ZoneSkyboxPlugin) // The realmlist (decision 1667) — the logon address the login screen edits. Same reason as // VideoPlugin above: it is a CVar knob, so its resource has to exist before `load_config`. .add(crate::realmlist::RealmlistPlugin) diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index a7f9ac23..862b5fc1 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -74,6 +74,8 @@ mod doodad_events; /// MONKEY (dynamic interiors): the cvar → benilla-world bridge for the fixture-lit interior lane. // MONKEY (volumetric fog): optional near-field atmosphere plugin. mod volumetric_fog; +// MONKEY (skybox): the zoneSkyboxes cvar bridge. +mod zone_skybox; mod dynamic_interior; mod entities; mod fishing_line; diff --git a/crates/benilla-app/src/zone_skybox.rs b/crates/benilla-app/src/zone_skybox.rs new file mode 100644 index 00000000..ce4342cc --- /dev/null +++ b/crates/benilla-app/src/zone_skybox.rs @@ -0,0 +1,45 @@ +//! MONKEY (skybox): the `zoneSkyboxes` cvar's bridge to [`benilla_world::skybox::ZoneSkyboxes`]. +//! `WOW_ZONE_SKYBOXES=0|1` overrides it for the session, which is how a hermetic capture (no config +//! file, registry defaults) turns the lane on. + +use bevy::prelude::*; + +/// The session override, read once. +#[derive(Resource, Clone, Copy)] +struct ZoneSkyboxOverride(Option); + +pub(crate) struct ZoneSkyboxPlugin; + +impl Plugin for ZoneSkyboxPlugin { + fn build(&self, app: &mut App) { + let forced = std::env::var("WOW_ZONE_SKYBOXES") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .map(|v| v != 0); + app.insert_resource(ZoneSkyboxOverride(forced)) + .add_observer(on_cvar) + .add_systems(Startup, apply_override); + } +} + +fn apply_override( + forced: Res, + zone: Option>, +) { + if let (Some(on), Some(mut zone)) = (forced.0, zone) { + zone.set_if_neq(benilla_world::skybox::ZoneSkyboxes(on)); + } +} + +fn on_cvar( + ev: On, + forced: Res, + zone: Option>, +) { + if !ev.is("zoneSkyboxes") || forced.0.is_some() { + return; + } + if let Some(mut zone) = zone { + zone.set_if_neq(benilla_world::skybox::ZoneSkyboxes(ev.flag())); + } +} From ccd9f42dabbcdce7fbcb2e0c380cae9ee39731c0 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 03:17:48 +0100 Subject: [PATCH 17/87] MONKEY (water): add Gerstner crest whitecaps Co-Authored-By: GPT-5.6 Sol (Codex) --- THIRD-PARTY.md | 7 ++ WATER.md | 3 + .../src/shaders/enhanced_water.wgsl | 63 ++++++++-- crates/benilla-assets/src/shaders/liquid.wgsl | 4 +- crates/benilla-world/src/liquid/waves.rs | 108 ++++++++++++++++-- 5 files changed, 165 insertions(+), 20 deletions(-) create mode 100644 THIRD-PARTY.md diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md new file mode 100644 index 00000000..ae7abaf7 --- /dev/null +++ b/THIRD-PARTY.md @@ -0,0 +1,7 @@ +# Third-party technique credits + +## WarcraftXL + +| Benilla component | Source | Author | Use | +| --- | --- | --- | --- | +| `enhanced_water.wgsl` Gerstner displacement and fold-driven High-quality whitecaps | [WarcraftXL `wxl-experimental-water`](https://github.com/WarcraftXL), `shaders/Surface.ps.hlsl`, `sea/Spectrum.*`, `sea/Ocean.*` | iThorgrim | Technique ported with the author's permission; attribution is required. | diff --git a/WATER.md b/WATER.md index f41a518d..0b077de0 100644 --- a/WATER.md +++ b/WATER.md @@ -40,6 +40,7 @@ the technique is listed in the table below. Do not remove either. |---|---|---| | Quality tiers | `Classic` = the reference water, `Enhanced`, `High` (= Enhanced + scenery reflections) | Video options → Water Quality, cvar `waterQuality`, env `WOW_WATER=0\|1\|2` | | Procedural waves | multi-band analytic waves with exact normals; the long swell moves ocean vertices | tier | +| Open-sea whitecaps | High adds Gerstner crest gathering and fold-thresholded foam; calm canals remain clear | tier | | Ocean / inland profiles | the sea and lakes/rivers have their own colour, energy and reflectivity | tier | | WMO pools | exterior canals use a calm drifting profile with outdoor glints; interiors use their authored MOMT colour and room-fog reflection | tier | | Depth look | light fades per channel over the real path through the water; the bed is seen through it, bent by the waves; sunlit shallows carry a caustic web | tier | @@ -80,4 +81,6 @@ request. If it ever returns it should read the authored MCLQ flow records, not d - Every screen derivative in `enhanced_water()` is taken at the top of the function (WGSL uniformity); WGSL only fails at pipeline creation, so verify by running a `water-*` scene. - Never rotate a noise domain by a per-pixel angle at world coordinates (~1e4 yd): it shears. +- The temporary water wind is `WATER_WIND_DIR`; all wind-aligned shader terms derive from it until + the weather lane supplies shared wind. - Run one GPU capture at a time, and run `cargo test` in a shell WITHOUT `WOW_DATA` exported. diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl index 7662d00d..a89ca38f 100644 --- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl +++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl @@ -1,5 +1,6 @@ #define_import_path benilla::enhanced_water +// Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-experimental-water, shaders/Surface.ps.hlsl, sea/Spectrum.*, sea/Ocean.*. // ENHANCED WATER - the optional water module (Video options -> Water Quality; see WATER.md). // // Credits: original project WarcraftXL (`wxl-experimental-water`, https://github.com/WarcraftXL), @@ -84,21 +85,25 @@ fn water_time() -> f32 { return globals.time; } -// Vertex lift for the ocean's long swell (0 everywhere else). `liquid/waves.rs` mirrors it on -// the CPU for the swimmer bob; the two are one contract. -fn water_swell(xz: vec2, authored_depth: f32) -> f32 { +// Vertex Gerstner displacement for the ocean's long swell (zero everywhere else). +// `liquid/waves.rs` mirrors it on the CPU for the swimmer bob; the two are one contract. +fn water_swell(xz: vec2, authored_depth: f32) -> vec3 { if water.lane.z < 0.5 && water.lane.y > 0.5 && water.lane.x < 0.5 && water.mode.x > 0.5 { - return water_waves(xz, water_time(), 0.0, 0.0, swell_shore_fade(authored_depth), true).x; + return water_gerstner(xz, water_time(), swell_shore_fade(authored_depth)).xyz; } - return 0.0; + return vec3(0.0); } // Enhanced is entirely analytic: height and its exact x/z derivatives, in yards. // Direction is radians from world +X toward +Z; phase speed follows deep-water dispersion. // Two mesh-resolvable long waves sum to at most 0.34 yd before energy/shore attenuation. +// MONKEY (water): one temporary wind bearing until the weather lane supplies shared wind. +// Every wind-aligned water term derives from this constant rather than owning a second heading. +const WATER_WIND_DIR: f32 = 0.35; +const WATER_GERSTNER_CHOP: f32 = 2.4; const WATER_WAVES: array, 8> = array, 8>( // direction, wavelength, amplitude, phase offset - vec4(0.35, 18.0, 0.200, 0.0), + vec4(WATER_WIND_DIR, 18.0, 0.200, 0.0), vec4(0.80, 12.8, 0.140, 1.7), vec4(-0.18, 9.5, 0.090, 3.1), vec4(0.52, 4.8, 0.055, 0.8), @@ -137,7 +142,7 @@ const SHORE_PERIOD: f32 = 3.6; // seconds between arrivals; the swash ru const SHORE_TILT: f32 = 0.09; // peak crest steepness (tan of the tilt), slope-independent const SHORE_WARP_A: f32 = 0.22; // yards of depth — coarse crest wander (never ruler-straight) const SHORE_WARP_B: f32 = 0.10; // yards of depth — finer segmentation of the same crests -const SHORE_WIND_DIR: f32 = 0.35; // = WATER_WAVES[0].x, the primary swell bearing +const SHORE_WIND_DIR: f32 = WATER_WIND_DIR; // Foam alphas. The owner rejected BOTH a thick icing sheet and straight stripes before this, so // every one of these is gated behind a noise breakup and a depth window; the numbers are the @@ -145,6 +150,7 @@ const SHORE_WIND_DIR: f32 = 0.35; // = WATER_WAVES[0].x, the primary swell const FOAM_WET_EDGE: f32 = 0.35; // the faint wet line where water meets anything solid const FOAM_SWASH: f32 = 0.62; // the sheet running up the sand and fading const FOAM_CREST: f32 = 0.88; // the white front of the last wave or two, and its lace +const FOAM_WHITECAP: f32 = 0.68; // High-only open-sea crest fold const FOAM_MAX: f32 = 0.90; // hard ceiling on the sum fn swell_shore_fade(depth: f32) -> f32 { @@ -182,6 +188,37 @@ fn water_waves(p: vec2, t: f32, distance: f32, footprint: f32, return result; } +// MONKEY (water): Ported from WarcraftXL's Gerstner displacement and fold-driven gFoam path. +// xyz is horizontal/vertical/horizontal displacement; w is 1 - det(J), the amount the horizontal +// map has compressed toward a fold. Only the two mesh-resolvable long bands move geometry. +fn water_gerstner(p: vec2, t: f32, shore: f32) -> vec4 { + let energy = clamp(water.mode.y, 0.0, 1.0); + let tempo = mix(0.4, 1.0, sqrt(energy)); + var displacement = vec3(0.0); + var j00 = 1.0; + var j01 = 0.0; + var j10 = 0.0; + var j11 = 1.0; + for (var i = 0u; i < 2u; i += 1u) { + let wave = WATER_WAVES[i]; + let direction = vec2(cos(wave.x), sin(wave.x)); + let k = 6.2831853 / wave.y; + let speed = sqrt(10.72 / k); + let phase = k * (dot(direction, p) - speed * tempo * t) + wave.w; + let amplitude = wave.z * energy * shore; + let horizontal = WATER_GERSTNER_CHOP * amplitude; + displacement += vec3(direction.x * horizontal * cos(phase), + amplitude * sin(phase), direction.y * horizontal * cos(phase)); + let compression = horizontal * k * sin(phase); + j00 -= compression * direction.x * direction.x; + j01 -= compression * direction.x * direction.y; + j10 -= compression * direction.y * direction.x; + j11 -= compression * direction.y * direction.y; + } + let fold = max(1.0 - (j00 * j11 - j01 * j10), 0.0); + return vec4(displacement, fold); +} + // MONKEY (water): seam for the later wet-weather lane. Its result is a height-gradient // perturbation in world XZ; zero preserves today's image until that lane supplies rain data. fn rain_ripple_normal(world_xz: vec2, time: f32) -> vec2 { @@ -472,6 +509,10 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec let shore = select(1.0, swell_shore_fade(in.depth), ocean_mesh); let wave = water_waves(p, t, length(eye_pos), footprint, shore, false); + var open_sea_fold = 0.0; + if water.mode.x > 1.5 && ocean_mesh { + 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, t); var n = normalize(vec3(-surf_grad.x, 1.0, -surf_grad.y)); @@ -672,6 +713,12 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec let noise = 0.7 * foam_noise(p * 0.6 + t * vec2(0.025, -0.018)) + 0.3 * foam_noise(p * 1.7 - t * vec2(0.014, 0.021)); let breakup = smoothstep(0.40, 0.72, noise); + // MONKEY (water): WarcraftXL's white belongs to a connected horizontal fold, not to every + // steep normal. Threshold jitter stops identical crests drawing identical white contours. + let fold_jitter = (noise - 0.5) * 0.04; + let breaking = smoothstep(0.13, 0.25, open_sea_fold + fold_jitter); + let whitecap_alpha = FOAM_WHITECAP * breaking * mix(0.55, 1.0, breakup) + * smoothstep(0.55, 1.0, energy); // The cached derivatives from the top of the function — same expression as before, one tap. let depth_gradient = length(vec2(ddx_depth, ddy_depth)) / max(length(vec2(length(ddx_p), length(ddy_p))), 0.001); @@ -721,7 +768,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec FOAM_SWASH * swash_life * swash_band * max(swash_lace, 0.8 * lip), ocean_mesh) * wall_suppression * energy; - let foam = min(FOAM_MAX, contact_alpha + arcs_alpha + crest_alpha); + let foam = min(FOAM_MAX, contact_alpha + arcs_alpha + crest_alpha + whitecap_alpha); let illumination = water_light[0].rgb + water_light[1].rgb; let foam_luma = clamp(dot(illumination, vec3(0.2126, 0.7152, 0.0722)), 0.22, 1.0); // Matte foam composites on top of reflection with its own coverage. diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl index 248497bf..ba16672d 100644 --- a/crates/benilla-assets/src/shaders/liquid.wgsl +++ b/crates/benilla-assets/src/shaders/liquid.wgsl @@ -137,8 +137,8 @@ fn vertex(in: Vertex) -> LiquidVsOut { let world_from_local = mesh_functions::get_world_from_local(in.instance_index); out.world_position = mesh_functions::mesh_position_local_to_world(world_from_local, vec4(in.position, 1.0)); - // MONKEY (enhanced water): the ocean's long swell; 0 on Classic and on every other surface. - out.world_position.y += water_swell(out.world_position.xz, in.uv_b.x); + // MONKEY (enhanced water): ocean Gerstner displacement; zero on Classic/other surfaces. + out.world_position.xyz += water_swell(out.world_position.xz, in.uv_b.x); out.clip_position = position_world_to_clip(out.world_position.xyz); out.world_normal = mesh_functions::mesh_normal_local_to_world(in.normal, in.instance_index); out.uv = in.uv; diff --git a/crates/benilla-world/src/liquid/waves.rs b/crates/benilla-world/src/liquid/waves.rs index 363a87fe..367b0c3f 100644 --- a/crates/benilla-world/src/liquid/waves.rs +++ b/crates/benilla-world/src/liquid/waves.rs @@ -31,10 +31,12 @@ use bevy::math::Vec2; /// /// Components 2..8 are the shader's fragment-only ripple band and are NOT here — see the module /// doc's `long_only` note. Adding them would be a second design, not a closer mirror. -pub const LONG_SWELL: [[f32; 4]; 2] = [ - [0.35, 18.0, 0.200, 0.0], - [0.80, 12.8, 0.140, 1.7], -]; +pub const WATER_WIND_DIR: f32 = 0.35; +pub const LONG_SWELL: [[f32; 4]; 2] = + [[WATER_WIND_DIR, 18.0, 0.200, 0.0], [0.80, 12.8, 0.140, 1.7]]; + +/// Horizontal Gerstner gather, mirrored from `enhanced_water.wgsl::WATER_GERSTNER_CHOP`. +pub const GERSTNER_CHOP: f32 = 2.4; /// Gravity in yd/s², `enhanced_water.wgsl` — deep-water dispersion, `c = sqrt(g/k)`. const GRAVITY_YD: f32 = 10.72; @@ -60,6 +62,8 @@ pub struct Swell { /// `(∂height/∂x, ∂height/∂z)` in BEVY world axes, the shader's `result.yz`. The shader builds /// its normal from exactly this as `normalize(vec3(-grad.x, 1, -grad.y))` (`enhanced_water.wgsl`). pub grad: Vec2, + /// Horizontal Gerstner displacement in BEVY XZ at the parameter-space point under this sample. + pub horizontal: Vec2, } /// `enhanced_water.wgsl`'s `swell_shore_fade`: the authored ocean depth `V` (byte/255, ≈148 yd at @@ -84,25 +88,69 @@ pub fn swell_shore_fade(depth_v: f32) -> f32 { /// * `long_only = true` — components 0 and 1, which are also the two the shore fade multiplies. /// /// `world_xz` is BEVY world XZ. `shallow_fade` is the shore term (1 = open sea, 0 = beach). -pub fn swell(world_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f32) -> Swell { +#[derive(Clone, Copy, Debug, Default)] +struct RawSwell { + height: f32, + grad: Vec2, + horizontal: Vec2, + jacobian: [f32; 4], +} + +/// Evaluate the same parameter-space Gerstner surface the vertex shader receives. The public +/// sampler below inverts its horizontal map because callers hold a displaced world position. +fn raw_swell(parameter_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f32) -> RawSwell { let energy = wave_energy.clamp(0.0, 1.0); // `mix(0.4, 1.0, sqrt(energy))` — a calm inland sheet also swells SLOWER, not merely lower. let tempo = 0.4 + 0.6 * energy.sqrt(); let shore = shallow_fade.clamp(0.0, 1.0); - let mut out = Swell::default(); + let mut out = RawSwell { + jacobian: [1.0, 0.0, 0.0, 1.0], + ..RawSwell::default() + }; for w in LONG_SWELL { let dir = Vec2::new(w[0].cos(), w[0].sin()); let k = TWO_PI / w[1]; let speed = (GRAVITY_YD / k).sqrt(); // phase speed, yd/s - let phase = k * (dir.dot(world_xz) - speed * tempo * time) + w[3]; + let phase = k * (dir.dot(parameter_xz) - speed * tempo * time) + w[3]; // `fade` is 1 at zero footprint; `i < 2` ⇒ both components take the shore term. let amplitude = w[2] * energy * shore; out.height += amplitude * phase.sin(); out.grad += dir * (amplitude * k * phase.cos()); + let horizontal = GERSTNER_CHOP * amplitude; + out.horizontal += dir * (horizontal * phase.cos()); + let compression = horizontal * k * phase.sin(); + out.jacobian[0] -= compression * dir.x * dir.x; + out.jacobian[1] -= compression * dir.x * dir.y; + out.jacobian[2] -= compression * dir.y * dir.x; + out.jacobian[3] -= compression * dir.y * dir.y; } out } +/// The displaced Gerstner surface at a WORLD-space point. Four fixed-point steps find the original +/// mesh coordinate under that point; the long-band Jacobian is contractive (worst compression +/// below 0.34), so this converges without a branch or an allocation. The gradient is transformed +/// through the same map, keeping swimmer lean consistent with the visibly pinched crest. +pub fn swell(world_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f32) -> Swell { + let mut parameter_xz = world_xz; + for _ in 0..4 { + let raw = raw_swell(parameter_xz, time, wave_energy, shallow_fade); + parameter_xz = world_xz - raw.horizontal; + } + let raw = raw_swell(parameter_xz, time, wave_energy, shallow_fade); + let [j00, j01, j10, j11] = raw.jacobian; + let det = (j00 * j11 - j01 * j10).max(1.0e-4); + let grad = Vec2::new( + (j11 * raw.grad.x - j01 * raw.grad.y) / det, + (-j10 * raw.grad.x + j00 * raw.grad.y) / det, + ); + Swell { + height: raw.height, + grad, + horizontal: raw.horizontal, + } +} + /// [`swell`]'s height alone — the shape the brief names, for callers that do not want the slope. pub fn swell_height(world_xz: Vec2, time: f32, wave_energy: f32, shallow_fade: f32) -> f32 { swell(world_xz, time, wave_energy, shallow_fade).height @@ -167,8 +215,19 @@ mod tests { "enhanced_water.wgsl WATER_WAVES[0..2]" ); assert_eq!(GRAVITY_YD, 10.72, "enhanced_water.wgsl gravity"); - assert_eq!(TWO_PI, 6.2831853, "enhanced_water.wgsl k = 6.2831853/wavelength"); - assert_eq!(OCEAN_WAVE_ENERGY, 1.0, "surface.rs water.mode.y for ADT ocean"); + assert_eq!( + TWO_PI, 6.2831853, + "enhanced_water.wgsl k = 6.2831853/wavelength" + ); + assert_eq!(WATER_WIND_DIR, 0.35, "enhanced_water.wgsl WATER_WIND_DIR"); + assert_eq!( + GERSTNER_CHOP, 2.4, + "enhanced_water.wgsl WATER_GERSTNER_CHOP" + ); + assert_eq!( + OCEAN_WAVE_ENERGY, 1.0, + "surface.rs water.mode.y for ADT ocean" + ); // The band's ceiling, stated in the shader's own comment ("at most 0.34 yd"). let peak: f32 = LONG_SWELL.iter().map(|w| w[2]).sum(); assert!((peak - 0.34).abs() < 1e-6, "amplitude sum {peak}"); @@ -183,11 +242,13 @@ mod tests { let s = swell(Vec2::new(-9016.0, 226.0), t, 0.0, 1.0); assert_eq!(s.height, 0.0, "height at t={t}"); assert_eq!(s.grad, Vec2::ZERO, "grad at t={t}"); + assert_eq!(s.horizontal, Vec2::ZERO, "horizontal at t={t}"); } // The shore term is the other kill switch: a beached vertex does not move either. let beached = swell(Vec2::new(-9016.0, 226.0), 3.0, OCEAN_WAVE_ENERGY, 0.0); assert_eq!(beached.height, 0.0); assert_eq!(beached.grad, Vec2::ZERO); + assert_eq!(beached.horizontal, Vec2::ZERO); } /// The swell stays inside the band the shader advertises, and the gradient it reports really @@ -220,12 +281,39 @@ mod tests { } } + /// The CPU query samples by displaced world position while the vertex shader starts in mesh + /// parameter space. Pin the inversion: adding the returned horizontal displacement to the + /// recovered parameter must land back under the swimmer, with the advertised hard bound. + #[test] + fn gerstner_inverse_lands_under_the_world_sample() { + let world = Vec2::new(-9016.0, 226.0); + for step in 0..40 { + let t = step as f32 * 0.37; + let s = swell(world, t, OCEAN_WAVE_ENERGY, 1.0); + let parameter = world - s.horizontal; + let raw = raw_swell(parameter, t, OCEAN_WAVE_ENERGY, 1.0); + let reprojection = parameter + raw.horizontal; + assert!( + (reprojection - world).length() < 2.0e-3, + "reprojection at t={t}" + ); + assert!( + s.horizontal.length() <= 0.817, + "horizontal at t={t}: {:?}", + s.horizontal + ); + } + } + /// The shore fade's two ends, as the shader's `smoothstep(0.001, 0.025, depth)` places them. #[test] fn shore_fade_matches_the_shader_band() { assert_eq!(swell_shore_fade(0.0), 0.0); assert_eq!(swell_shore_fade(1.0), 1.0, "open sea (V = 1) is unfaded"); - assert!((swell_shore_fade(0.013) - 0.5).abs() < 0.02, "band midpoint"); + assert!( + (swell_shore_fade(0.013) - 0.5).abs() < 0.02, + "band midpoint" + ); } /// The clock: a live run is the wrapped elapsed seconds verbatim (the GPU's `globals.time`), From bd65cb5eaf1106b88e06ed052df975f16486fe68 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 03:47:36 +0100 Subject: [PATCH 18/87] MONKEY (ao): import ShaderDefVal Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/ssao.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/benilla-app/src/ssao.rs b/crates/benilla-app/src/ssao.rs index 0af856b4..a7c8c81d 100644 --- a/crates/benilla-app/src/ssao.rs +++ b/crates/benilla-app/src/ssao.rs @@ -34,6 +34,7 @@ use bevy::{ view::{Msaa, ViewDepthTexture, ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms}, Render, RenderApp, RenderStartup, RenderSystems, }, + shader::ShaderDefVal, }; pub(crate) struct AmbientOcclusionPlugin; From 91ac4d148159d77b7659f994ca305e4de0ba98f5 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 04:09:34 +0100 Subject: [PATCH 19/87] =?UTF-8?q?MONKEY=20(sky):=20skyQuality=20tier=20?= =?UTF-8?q?=E2=80=94=20smooth=20gradient,=20sun=20glow,=20night=20sky,=20H?= =?UTF-8?q?igh=20cloud=20detail=20+=20sun=20march?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit skyQuality 0 Classic (unchanged), 1 Enhanced, 2 High; Advanced Graphics row (EN+RU), cvar bridge, sky-* scenarios above the canopy. Cloud technique ported from WarcraftXL wxl-retail-clouds (THIRD-PARTY.md). Co-Authored-By: Claude Opus 5.5 --- LIGHTING.md | 2 + THIRD-PARTY.md | 13 + crates/benilla-app/assets/ui/OptionsFrame.xml | 36 ++- crates/benilla-app/src/capture/scenarios.rs | 33 ++- crates/benilla-app/src/cvars.rs | 6 + crates/benilla-app/src/game_plugins.rs | 2 + crates/benilla-app/src/lib.rs | 2 + crates/benilla-app/src/sky_quality.rs | 49 ++++ .../src/ui_script/options_tests.rs | 2 + crates/benilla-app/src/video.rs | 6 + crates/benilla-world/src/clouds/layer.rs | 52 ++++ crates/benilla-world/src/clouds/mod.rs | 2 + crates/benilla-world/src/lib.rs | 2 + crates/benilla-world/src/shaders.rs | 5 +- crates/benilla-world/src/shaders/cloud.wgsl | 47 ++- crates/benilla-world/src/shaders/sky.wgsl | 44 +++ crates/benilla-world/src/shaders/sky_fx.wgsl | 269 ++++++++++++++++++ crates/benilla-world/src/sky.rs | 58 +++- crates/benilla-world/src/sky_fx.rs | 117 ++++++++ crates/benilla-world/src/world_plugins.rs | 2 + 20 files changed, 734 insertions(+), 15 deletions(-) create mode 100644 THIRD-PARTY.md create mode 100644 crates/benilla-app/src/sky_quality.rs create mode 100644 crates/benilla-world/src/shaders/sky_fx.wgsl create mode 100644 crates/benilla-world/src/sky_fx.rs diff --git a/LIGHTING.md b/LIGHTING.md index 2122d3df..d0a9c7cd 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -24,6 +24,7 @@ behind each constant. | Moon shadows | at night the same shadow rig re-aims at the moon and casts a faint shadow; dims only the night sky term, never point lights | `moonShadowStrength` | | Ground-effect spells | Flamestrike, Rain of Fire, Consecration, Flare and fire traps light the ground for their duration; frost and nature areas stay dark | `spellLightGain` | | Volumetric fog | near-field haze that converges on the zone fog colour (clear within 10 yd, full by 150 yd; mistier at dawn and in bad weather, faint indoors) and sun/moon light shafts through gaps, sampled from the shadow map; own fullscreen pass after the main pass (`benilla-app/src/volumetric_fog.rs`) | Advanced Graphics → Volumetric Fog (Off/Low/High), cvar `volumetricFog`, env `WOW_VOLFOG=0\|1\|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` | | Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` | Players reach all of it from **Options -> Advanced Graphics** (a Lighting Quality preset Off / Low / Medium / High plus the individual rows; Off is the original client look). The dev build has a panel for all of it: **Ctrl+Shift+D → Lighting & shadows**, with Dim / Default / @@ -47,6 +48,7 @@ is its own module, documented in `WATER.md`. `character_shadow.rs`, `world_shadow.rs`, `blob_shadow.rs`, `entities/carried_light.rs`, `entities/spell_fx/lifecycle.rs`, `dynamic_interior.rs`, `debug_panel/lighting_controls.rs`, and the cvars in `cvars.rs` / `video.rs`. +- **Sky** (`crates/benilla-world/src`): `sky_fx.rs` (tier, clock, glow inputs), `shaders/sky_fx.wgsl` (gradient curve, glow, stars, cloud noise), the tier branches in `shaders/sky.wgsl` and `shaders/cloud.wgsl`, `clouds/layer.rs` (`update_cloud_fx`); the cvar bridge is `benilla-app/src/sky_quality.rs`. - **Tools**: `benilla-extract wmolights [--verts ]`, `wmolamps`, `m2firescan` print the inputs the system works from (groups, batch classes, portals, claims, flame emitters). diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md new file mode 100644 index 00000000..d28892d1 --- /dev/null +++ b/THIRD-PARTY.md @@ -0,0 +1,13 @@ +# Third-party code and techniques + +## WarcraftXL + +[WarcraftXL](https://github.com/WarcraftXL) by **iThorgrim** is a 3.3.5 client extension. The files +below port code or a technique from it; each carries the header line +`Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module , .` + +| File | WarcraftXL module | Source files | What was taken | +|---|---|---|---| +| `crates/benilla-world/src/shaders/sky_fx.wgsl` | wxl-retail-clouds | `Clouds.cpp`, `Clouds.hpp` | Domain-warped billow fbm (quintic value noise, 0.6 cotton blend, 0.55 octave gain) for the cloud detail | +| `crates/benilla-world/src/shaders/cloud.wgsl` | wxl-retail-clouds | `Clouds.cpp` | The 3-tap march toward the sun (self-shadow, ambient floor) and the one-tap silver lining | +| `crates/benilla-world/src/clouds/layer.rs` (`update_cloud_fx`) | wxl-retail-clouds | `Clouds.cpp` | March direction = the sun projected on the sheet, strength folded by its flatness (noon clouds evenly lit) | diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 71e19e82..b1d74e0b 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -494,6 +494,8 @@ lightingQuality = "Lighting Quality", -- MONKEY (volumetric fog): localised atmospheric quality row. volumetricFog = "Volumetric Fog", + -- MONKEY (sky): the sky tier row. + skyQuality = "Sky Quality", fogOff = "Off", waterQuality = "Water Quality", waterClassic = "Classic", @@ -534,6 +536,12 @@ -- MONKEY (volumetric fog): explain cost and the shadow-map dependency. VOLUMETRIC_FOG = "Off disables volumetric fog. Low adds nearby haze and sun or moon shafts with fewer steps; High uses more steps for smoother shafts. Mist is heavier at dawn and in bad weather, and subtle indoors. Shafts require shadows; with shadows off only haze remains.", + -- MONKEY (sky): what each sky tier adds. + SKY_QUALITY = + "Classic is the original client's sky. Enhanced smooths the sky's colour " + .. "gradient, adds a soft glow around the sun and a starry night sky with the " + .. "Milky Way. High also gives the clouds finer detail and sunlit edges. " + .. "Costs very little.", WATER_QUALITY = "Classic is the original client's water. Enhanced adds soft shorelines, " .. "animated foam around shores and objects, moving wave highlights, sky reflection " @@ -618,6 +626,8 @@ lightingQuality = "Качество освещения", -- MONKEY (volumetric fog): Russian atmospheric quality row. volumetricFog = "Объёмный туман", + -- MONKEY (sky): the sky tier row. + skyQuality = "Качество неба", fogOff = "Выкл", waterQuality = "Качество воды", waterClassic = "Классическое", @@ -660,6 +670,12 @@ -- MONKEY (volumetric fog): localised effect and cost explanation. VOLUMETRIC_FOG = "Выкл отключает объёмный туман. Низкое добавляет дымку вблизи и лучи солнца или луны с меньшим числом шагов; Высокое использует больше шагов для более плавных лучей. Туман гуще на рассвете и в непогоду, а в помещениях слабее. Для лучей нужны тени; без теней остаётся только дымка.", + -- MONKEY (sky): what each sky tier adds. + SKY_QUALITY = + "«Классическое» — небо оригинального клиента. «Улучшенное» сглаживает " + .. "цветовой переход неба, добавляет мягкое сияние вокруг солнца и звёздное " + .. "ночное небо с Млечным Путём. «Высокое» также добавляет облакам мелкие " + .. "детали и освещённые солнцем края. Почти не влияет на производительность.", WATER_QUALITY = "«Классическое» — вода оригинального клиента. «Улучшенное» добавляет плавные переходы у берегов, " .. "анимированную пену у берегов и вокруг объектов, подвижные блики на волнах, отражение неба " @@ -796,6 +812,8 @@ AdvancedGraphics = { -- MONKEY (volumetric fog): include in refresh, defaults and search. "RowLightingQuality", "RowWaterQuality", "RowVolumetricFog", + -- MONKEY (sky): the sky tier row. + "RowSkyQuality", "RowCharacterShadows", "RowWorldShadows", "RowShadowDistance", "RowShadowResolution", "RowMoonShadows", "RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft", @@ -2602,11 +2620,27 @@ }) - + + + + OptionsRow_OnLoad(self, "skyQuality", BENILLA_ADVGFX.skyQuality, + "BENILLA_TOOLTIP_SKY_QUALITY") + OptionsDropdownRow_Setup(self, { + { text = BENILLA_ADVGFX.waterClassic, value = "0" }, + { text = BENILLA_ADVGFX.waterEnhanced, value = "1" }, + { text = BENILLA_ADVGFX.waterHigh, value = "2" }, + }) + + + + + + + OptionsRow_OnLoad(self, "characterShadows", BENILLA_ADVGFX.characterShadows, diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index 7da0b3fa..2e1ef189 100644 --- a/crates/benilla-app/src/capture/scenarios.rs +++ b/crates/benilla-app/src/capture/scenarios.rs @@ -253,6 +253,8 @@ pub(super) enum SubjectKind { pub(super) const GROUND_EYE: [f32; 3] = [-8980.0, -160.0, 110.0]; pub(super) const GROUND_LOOK: [f32; 3] = [-8949.95, -132.49, 84.0]; pub(super) const SKY_EYE: [f32; 3] = [-8980.0, -160.0, 112.0]; +/// MONKEY (sky): 80 yd over the Northshire sky eye, above every canopy. +pub(super) const SKY_HIGH_EYE: [f32; 3] = [-8980.0, -160.0, 190.0]; pub(super) const SKY_LOOK: [f32; 3] = [-8740.0, 80.0, 168.0]; // up + out: horizon in the lower third, dome above // Farmhouse viewpoints (decision 0071): compass looks from the human-start login spot. Kept @@ -1379,41 +1381,50 @@ pub(super) const ON_DEMAND: &[Scenario] = &[ minute: 0, ui: None, }, - // MONKEY (sky): the sky lane's fixtures. Dusk looks from the Goldshire lake toward the low sun - // (az 45°, elev ≈5° at 20:00); night looks away from the moon at 01:00 (stars at full curve); - // zenith is the high dome at night. Overcast is `sky-overcast` under `WOW_WEATHER=rain,0.6`. + // MONKEY (sky): the sky lane's fixtures, from 80 yd above Northshire (clear of the canopy). + // Dusk faces the low sun (az 45°, elev ≈5° at 20:00); night faces away from the moon at 01:00 + // (full star curve); zenith looks nearly straight up; overcast is meant for + // `WOW_WEATHER=1,0.5`; noon faces away from the sun. Scenario { name: "sky-elwynn-dusk", map: Some(MAP_AZEROTH), - eye: WATER_EYE, - look: [-9315.0, -98.6, 102.3], + eye: SKY_HIGH_EYE, + look: [-8770.0, 50.0, 232.0], minute: 1200, ui: None, }, Scenario { name: "sky-elwynn-night", map: Some(MAP_AZEROTH), - eye: WATER_EYE, - look: [-9739.0, -522.6, 210.8], + eye: SKY_HIGH_EYE, + look: [-9164.0, -344.0, 340.0], minute: 60, ui: None, }, Scenario { name: "sky-zenith-night", map: Some(MAP_AZEROTH), - eye: SKY_EYE, - look: [-9050.7, -230.7, 387.0], + eye: SKY_HIGH_EYE, + look: [-9017.0, -197.0, 485.0], minute: 60, ui: None, }, Scenario { name: "sky-overcast", map: Some(MAP_AZEROTH), - eye: SKY_EYE, - look: [-8797.0, 23.0, 264.0], + eye: SKY_HIGH_EYE, + look: [-9185.0, 45.0, 268.0], minute: 1000, ui: None, }, + Scenario { + name: "sky-noon", + map: Some(MAP_AZEROTH), + eye: SKY_HIGH_EYE, + look: [-9185.0, -365.0, 268.0], + minute: 720, + ui: None, + }, ]; /// The owner's reported vantage for the lava-glow report: `(-7048.8, -1000.6, 242.0)` facing diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 31a7eced..a72f81f4 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -921,6 +921,12 @@ pub(crate) const REGISTERED: &[Registered] = &[ ), // MONKEY (volumetric fog): saved live tier; capture override stays session-only. ours("volumetricFog", "1", "benilla's own: near-field volumetric fog, 0 Off / 1 Low / 2 High"), + // MONKEY (sky): the sky tier; the High graphics preset sets 2. + ours( + "skyQuality", + "0", + "benilla's own: sky quality, 0 Classic / 1 Enhanced (smooth gradient, sun glow, stars) / \n 2 High (+ detailed sun-lit clouds)", + ), ours( "waterQuality", "1", diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index adda66ec..2e3fe53b 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -245,6 +245,8 @@ impl PluginGroup for GamePlugins { .add(crate::dynamic_interior::DynamicInteriorPlugin) // MONKEY (volumetric fog): bridge the live setting to Bevy. .add(crate::volumetric_fog::VolumetricFogPlugin) + // MONKEY (sky): the sky tier bridge. + .add(crate::sky_quality::SkyQualityPlugin) // The realmlist (decision 1667) — the logon address the login screen edits. Same reason as // VideoPlugin above: it is a CVar knob, so its resource has to exist before `load_config`. .add(crate::realmlist::RealmlistPlugin) diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index a7f9ac23..2f38edc0 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -75,6 +75,8 @@ mod doodad_events; // MONKEY (volumetric fog): optional near-field atmosphere plugin. mod volumetric_fog; mod dynamic_interior; +// MONKEY (sky): the skyQuality cvar bridge. +mod sky_quality; mod entities; mod fishing_line; mod footprints; diff --git a/crates/benilla-app/src/sky_quality.rs b/crates/benilla-app/src/sky_quality.rs new file mode 100644 index 00000000..e56503e7 --- /dev/null +++ b/crates/benilla-app/src/sky_quality.rs @@ -0,0 +1,49 @@ +//! MONKEY (sky): the `skyQuality` cvar → [`benilla_world::sky_fx::SkyQuality`] bridge. +//! +//! 0 Classic (the reference's sky, unchanged), 1 Enhanced (smooth gradient, sun glow, night sky), +//! 2 High (+ detailed, sun-lit clouds). `$WOW_SKY_QUALITY` pins the tier for the session, so +//! captures stay independent of `config.toml`. + +use bevy::prelude::*; +use benilla_world::sky_fx::SkyQuality; + +use crate::video::VideoConfig; + +pub(crate) struct SkyQualityPlugin; + +impl Plugin for SkyQualityPlugin { + fn build(&self, app: &mut App) { + app.init_resource::().add_systems(Update, bridge); + } +} + +fn bridge(video: Res, mut out: ResMut, mut pinned: Local>>) { + let pin = *pinned.get_or_insert_with(SkyQuality::env_override); + let want = SkyQuality(pin.unwrap_or(video.sky_quality).min(SkyQuality::MAX)); + if *out != want { + *out = want; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bridge_publishes_the_tier_without_dirtying_idle_frames() { + if SkyQuality::env_override().is_some() { + return; // pinned by the environment; nothing to observe + } + let mut app = App::new(); + app.init_resource::().add_plugins(SkyQualityPlugin); + app.update(); + assert_eq!(app.world().resource::().0, VideoConfig::default().sky_quality); + for tier in [2u8, 1, 0] { + app.world_mut().resource_mut::().sky_quality = tier; + app.update(); + assert_eq!(app.world().resource::().0, tier); + app.update(); + assert!(!app.world().resource_ref::().is_changed()); + } + } +} diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 43d3f917..812352c0 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -3000,6 +3000,8 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() { const BENILLA_OWNED: &[(&str, &str)] = &[ // MONKEY (volumetric fog): the row owns a translated tooltip too. ("BENILLA_TOOLTIP_VOLUMETRIC_FOG", "AdvancedGraphicsRowVolumetricFog"), + // MONKEY (sky): the sky tier row owns its translated tooltip. + ("BENILLA_TOOLTIP_SKY_QUALITY", "AdvancedGraphicsRowSkyQuality"), ("BENILLA_TOOLTIP_WATER_QUALITY", "AdvancedGraphicsRowWaterQuality"), ("BENILLA_TOOLTIP_LAVA_GLOW", "AdvancedGraphicsRowLavaGlow"), ("BENILLA_TOOLTIP_RENDER_SCALE", "GraphicsRowRenderScale"), diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index 07c17824..0fcc5c1e 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -222,6 +222,8 @@ pub(crate) struct VideoConfig { pub(crate) water_quality: u8, // MONKEY (volumetric fog): live camera raymarch tier: 0 Off, 1 Low, 2 High. pub(crate) volumetric_fog: u8, + // MONKEY (sky): the sky tier: 0 Classic, 1 Enhanced, 2 High (`sky_quality::bridge`). + pub(crate) sky_quality: u8, /// Brightness of lava lighting its surroundings, 0..4; 0 disables the glow. /// Published to `benilla_world::lighting::LavaLightGain` by `dynamic_interior::bridge`. pub(crate) lava_light_gain: f32, @@ -467,6 +469,8 @@ impl Default for VideoConfig { water_quality: 1, // MONKEY (volumetric fog): default to the inexpensive atmosphere. volumetric_fog: 1, + // MONKEY (sky): Classic until a preset or the player picks a tier. + sky_quality: 0, lava_light_gain: 1.0, fire_flicker: 1.0, display: if windowed_env() { @@ -547,6 +551,8 @@ pub(crate) fn on_cvar( "waterquality" => cfg.water_quality = v.clamp(0.0, 2.0) as u8, // MONKEY (volumetric fog): constrain UI/console writes to supported tiers. "volumetricfog" => cfg.volumetric_fog = v.clamp(0.0, 2.0) as u8, + // MONKEY (sky): the sky tier, clamped to Classic..High. + "skyquality" => cfg.sky_quality = v.clamp(0.0, 2.0) as u8, "lavalightgain" => cfg.lava_light_gain = v.clamp(0.0, 4.0), "worldshadows" => cfg.world_shadows = ev.flag(), "charactershadows" => cfg.character_shadows = ev.flag(), diff --git a/crates/benilla-world/src/clouds/layer.rs b/crates/benilla-world/src/clouds/layer.rs index c1abfe8c..b76e8024 100644 --- a/crates/benilla-world/src/clouds/layer.rs +++ b/crates/benilla-world/src/clouds/layer.rs @@ -34,6 +34,13 @@ pub struct CloudExt { #[texture(100)] #[sampler(101)] pub(crate) texels: Handle, + /// MONKEY (sky): `x` the sky tier (detail at 2), `y` the sky clock, `zw` the unit direction + /// toward the glow body on the sheet ([`update_cloud_fx`]). + #[uniform(102)] + pub(crate) fx: Vec4, + /// MONKEY (sky): the Light.dbc cloud sun colour (`rgb`, gamma) and the march strength (`w`). + #[uniform(102)] + pub(crate) lit: Vec4, } impl MaterialExtension for CloudExt { @@ -156,6 +163,9 @@ pub(super) fn setup_cloud_layer( }, extension: CloudExt { texels: image.clone(), + // MONKEY (sky): Classic until `update_cloud_fx` reads the tier. + fx: Vec4::ZERO, + lit: Vec4::ZERO, }, }); commands.spawn(( @@ -213,6 +223,48 @@ pub(super) fn follow_cloud_dome( *gt = GlobalTransform::from(*tf); } +/// MONKEY (sky): feeds the High cloud shading. +/// Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-retail-clouds, Clouds.cpp. +/// The march walks the sheet toward the glow body +/// (sun by day, moon by night), scaled by how far the body sits off the zenith (a noon sun has no +/// direction on the sheet worth marching, as in WarcraftXL) and by the kernel's own glow envelope. +pub(super) fn update_cloud_fx( + light: Res, + quality: Res, + clock: Res, + layer: Option>, + mut materials: ResMut>, +) { + let Some(layer) = layer else { + return; + }; + let (fx, lit) = if quality.high() { + let body = light.cloud_glow_dir; + let flat = Vec2::new(body.x, body.z); + let len = flat.length(); + let d = if len > 1e-3 { flat / len } else { Vec2::ZERO }; + let strength = + (len * 1.6).min(1.0) * light.cloud_glow_track * (1.0 - 0.75 * light.storm_bcc); + let q = |v: f32| benilla_assets::quantize(v, 4096.0); + let sun = light.cloud_colors[0]; + ( + Vec4::new(f32::from(quality.0), clock.secs, q(d.x), q(d.y)), + Vec4::new(q(sun[0]), q(sun[1]), q(sun[2]), q(strength.clamp(0.0, 1.0))), + ) + } else { + (Vec4::ZERO, Vec4::ZERO) + }; + benilla_assets::write_gated( + &mut materials, + &layer.material, + |m| m.extension.fx != fx || m.extension.lit != lit, + |m| { + m.extension.fx = fx; + m.extension.lit = lit; + }, + ); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/benilla-world/src/clouds/mod.rs b/crates/benilla-world/src/clouds/mod.rs index aca2e21e..8464004e 100644 --- a/crates/benilla-world/src/clouds/mod.rs +++ b/crates/benilla-world/src/clouds/mod.rs @@ -69,6 +69,8 @@ impl Plugin for CloudsPlugin { layer::apply_cloud_visibility .after(crate::skybox::SkyboxResolve) .after(crate::liquid::SubmersionVerdict), + // MONKEY (sky): the High cloud shading's per-frame inputs. + layer::update_cloud_fx.in_set(crate::lighting::LightingConsumeSet), ), ) // Camera-anchored after transform propagation, like the sky dome. diff --git a/crates/benilla-world/src/lib.rs b/crates/benilla-world/src/lib.rs index 02aef8e8..0fb8964a 100644 --- a/crates/benilla-world/src/lib.rs +++ b/crates/benilla-world/src/lib.rs @@ -144,6 +144,8 @@ pub mod rig_rider; pub mod schedule; mod shaders; pub mod sky; +// MONKEY (sky): the sky quality tier and the Enhanced/High inputs. +pub mod sky_fx; pub mod sky_order; pub mod skybox; pub mod static_gx; diff --git a/crates/benilla-world/src/shaders.rs b/crates/benilla-world/src/shaders.rs index 52183c43..058ced84 100644 --- a/crates/benilla-world/src/shaders.rs +++ b/crates/benilla-world/src/shaders.rs @@ -11,6 +11,8 @@ use bevy::prelude::*; pub(crate) fn plugin(app: &mut App) { bevy::asset::embedded_asset!(app, "shaders/sky_vertex.wgsl"); bevy::asset::embedded_asset!(app, "shaders/sky.wgsl"); + // MONKEY (sky): the Enhanced/High library `sky.wgsl` and `cloud.wgsl` import. + bevy::asset::embedded_asset!(app, "shaders/sky_fx.wgsl"); bevy::asset::embedded_asset!(app, "shaders/star.wgsl"); bevy::asset::embedded_asset!(app, "shaders/cloud.wgsl"); bevy::asset::embedded_asset!(app, "shaders/celestial.wgsl"); @@ -59,6 +61,7 @@ mod tests { // `wow_model.wgsl`); 7 since 1429 added `static_gx.wgsl` (the B1 retained pass); // 8 since 2016 added `sky_vertex.wgsl` (the sky's shared far-depth vertex stage); // 9 since the torch-shadow Phase 1 added `torch_depth.wgsl` (the interior depth map). - assert_eq!(found, 9, "the engine's shader set changed size"); + // MONKEY (sky): 10 with `sky_fx.wgsl`, the sky's Enhanced/High library. + assert_eq!(found, 10, "the engine's shader set changed size"); } } diff --git a/crates/benilla-world/src/shaders/cloud.wgsl b/crates/benilla-world/src/shaders/cloud.wgsl index 084b54b8..91b18485 100644 --- a/crates/benilla-world/src/shaders/cloud.wgsl +++ b/crates/benilla-world/src/shaders/cloud.wgsl @@ -2,19 +2,64 @@ // the image the reference binds as its texture (`0x58ac70`). This stage applies the dome's rim // fade (`0x6d0530`) and blends the raw gamma texels premultiplied. Depth is the far pin in // `sky_vertex.wgsl`. +// +// MONKEY (sky): at `skyQuality` 2 the texels get GPU detail and a short sun march. +// Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-retail-clouds, Clouds.cpp. #import bevy_pbr::forward_io::VertexOutput +#import benilla_world::sky_fx @group(#{MATERIAL_BIND_GROUP}) @binding(100) var cloud_tex: texture_2d; @group(#{MATERIAL_BIND_GROUP}) @binding(101) var cloud_samp: sampler; +// MONKEY (sky): `fx.x` the sky tier, `.y` the sky clock (s), `.zw` the unit direction toward the +// glow body on the sheet (u = world x, v = world z); `lit.rgb` the Light.dbc cloud sun colour +// (gamma), `.w` the march strength (the body's flatness × the glow envelope). +struct CloudFx { + fx: vec4, + lit: vec4, +}; +@group(#{MATERIAL_BIND_GROUP}) @binding(102) var cfx: CloudFx; + +// MONKEY (sky): coverage with detail at a sheet point; `oct` detail octaves. +fn detailed_cover(uv: vec2, oct: i32) -> f32 { + let a = textureSampleLevel(cloud_tex, cloud_samp, uv, 0.0).a; + if (a <= 0.0) { + return 0.0; + } + return sky_fx::cloud_erode(a, sky_fx::cloud_detail(uv, cfx.fx.y, oct)); +} + @fragment fn fragment(in: VertexOutput) -> @location(0) vec4 { let texel = textureSample(cloud_tex, cloud_samp, in.uv); var a = texel.a; + var rgb = texel.rgb; + // MONKEY (sky): High only; Classic and Enhanced keep the kernel's texels as they are. + if (cfx.fx.x >= 1.5) { + let n = sky_fx::cloud_detail(in.uv, cfx.fx.y, 4); + a = sky_fx::cloud_erode(a, n); + // A painterly tone inside the mass from the same detail. + rgb = rgb * (0.93 + 0.14 * n); + let strength = cfx.lit.w; + if (a > 0.003 && strength > 0.0) { + let sd = cfx.fx.zw; + let step = 0.011; + var occl = detailed_cover(in.uv + sd * step, 2); + occl += detailed_cover(in.uv + sd * step * 2.0, 2) * 0.75; + occl += detailed_cover(in.uv + sd * step * 3.5, 2) * 0.5; + let transmit = exp(-occl * 1.44 * strength); + // WarcraftXL's ambient floor 0.55, softened toward the painted texel. + let light = 0.62 + 0.38 * transmit; + let ahead = detailed_cover(in.uv + sd * step * 1.5, 2); + let rim = max(ahead - a, 0.0) * (1.0 - a) * transmit * strength; + rgb = rgb * mix(1.0, light, strength) + cfx.lit.rgb * rim * 0.9; + rgb = min(rgb, vec3(1.0)); + } + } #ifdef VERTEX_COLORS a *= in.color.a; // the dome's rim fade (ring alphas) #endif // Premultiplied gamma blend; the RGB is already the reference's byte math. - return vec4(texel.rgb * a, a); + return vec4(rgb * a, a); } diff --git a/crates/benilla-world/src/shaders/sky.wgsl b/crates/benilla-world/src/shaders/sky.wgsl index e25ae0fa..c1f371f6 100644 --- a/crates/benilla-world/src/shaders/sky.wgsl +++ b/crates/benilla-world/src/shaders/sky.wgsl @@ -5,6 +5,8 @@ forward_io::VertexOutput, mesh_view_bindings::view, } +// MONKEY (sky): the Enhanced/High library (smooth gradient, sun glow, night sky). +#import benilla_world::sky_fx struct SkyColors { sky0: vec4, // zenith (90°) @@ -14,6 +16,13 @@ struct SkyColors { sky4: vec4, // 1.8° fog: vec4, // horizon (0°) and below: LightIntBand row 7 warp: vec4, // x = dawn/dusk warp strength S (0 = off), y = sun azimuth (rad), zw reserved + // MONKEY (sky): x = skyQuality (0 Classic), y = sky clock (s), z = night-sky alpha, w = glow + // strength. + fx: vec4, + // MONKEY (sky): xyz = camera to the visible sun, w unused. + sun: vec4, + // MONKEY (sky): rgb = glow colour (gamma), w unused. + glow: vec4, }; @group(#{MATERIAL_BIND_GROUP}) @binding(100) var sky: SkyColors; @@ -66,6 +75,41 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 { s4 = mix(warp_one(sky.sky4.rgb, g0, warp_s), warp_one(sky.sky4.rgb, g1, warp_s), f); } + // MONKEY (sky): Enhanced/High. The same stops through a monotone cubic in linear light, then + // the sun glow and the night sky added in linear light, back to gamma, dithered. + if (sky.fx.x >= 0.5) { + let stops = array, 6>( + sky_fx::srgb_to_linear(sky.fog.rgb), + sky_fx::srgb_to_linear(s4), + sky_fx::srgb_to_linear(s3), + sky_fx::srgb_to_linear(s2c), + sky_fx::srgb_to_linear(s1), + sky_fx::srgb_to_linear(sky.sky0.rgb), + ); + var lin = sky_fx::smooth_gradient(elev, stops); + // Sun glow: a halo tinted by the sun and fog colours, faded at night and under cloud on the + // CPU; below the horizon it thins out over the fog band. + if (sky.fx.w > 0.0) { + let below = smoothstep(-0.12, 0.02, dir.y); + lin += sky_fx::srgb_to_linear(sky.glow.rgb) + * (sky.fx.w * below * sky_fx::sun_glow(dir, normalize(sky.sun.xyz))); + } + // Night sky: stars and the Milky Way, fixed to world directions, thinned toward the + // horizon. The cloud dome draws over this, so clouds hide it where they stand. + let night = sky.fx.z; + // The pixel's size in star-grid units; derivatives stay in uniform control flow. + let px = length(fwidth(dir)) * sky_fx::STAR_GRID * 0.5; + if (night > 0.0 && dir.y > 0.0) { + let band = sky_fx::galaxy_band(dir); + let stars = sky_fx::star_field(dir, sky.fx.y, max(px, 1e-4), band) + * smoothstep(0.0, 0.2, dir.y); + let milky = sky_fx::milky_way(dir, band) * 0.05 * smoothstep(0.03, 0.35, dir.y); + lin += (stars * 0.85 + milky) * night; + } + let out = sky_fx::linear_to_srgb(lin) + vec3(sky_fx::dither_tri(in.position.xy)); + return vec4(clamp(out, vec3(0.0), vec3(1.0)), 1.0); + } + // Elevation gradient, linear between rings like the reference's Gouraud-shaded dome. var col: vec3; if (elev <= 0.0) { diff --git a/crates/benilla-world/src/shaders/sky_fx.wgsl b/crates/benilla-world/src/shaders/sky_fx.wgsl new file mode 100644 index 00000000..ed74f870 --- /dev/null +++ b/crates/benilla-world/src/shaders/sky_fx.wgsl @@ -0,0 +1,269 @@ +// MONKEY (sky): the Enhanced/High sky library (`skyQuality` ≥ 1), imported by `sky.wgsl` and +// `cloud.wgsl`. Classic never calls into it. Colour math is in linear light; the callers convert +// the gamma stops in and the result back out. +// +// The cloud detail (`cloud_billow`, `cloud_detail`): +// Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-retail-clouds, Clouds.cpp, Clouds.hpp. +#define_import_path benilla_world::sky_fx + +const TAU: f32 = 6.2831853; + +fn srgb_to_linear(c: vec3) -> vec3 { + let lo = c / 12.92; + let hi = pow((max(c, vec3(0.0)) + 0.055) / 1.055, vec3(2.4)); + return select(hi, lo, c <= vec3(0.04045)); +} + +fn linear_to_srgb(c: vec3) -> vec3 { + let lo = c * 12.92; + let hi = 1.055 * pow(max(c, vec3(0.0)), vec3(1.0 / 2.4)) - 0.055; + return select(hi, lo, c <= vec3(0.0031308)); +} + +// ---- hashes and noise ------------------------------------------------------------------------- + +// pcg3d (Jarzynski & Olano 2020): three well-mixed u32 from three. +fn pcg3(v_in: vec3) -> vec3 { + var v = v_in * 1664525u + 1013904223u; + v.x += v.y * v.z; + v.y += v.z * v.x; + v.z += v.x * v.y; + v = v ^ (v >> vec3(16u)); + v.x += v.y * v.z; + v.y += v.z * v.x; + v.z += v.x * v.y; + return v; +} + +fn hash33(p: vec3) -> vec3 { + return vec3(pcg3(bitcast>(p)) >> vec3(8u)) * (1.0 / 16777216.0); +} + +fn hash_cell2(p: vec2, salt: u32) -> f32 { + return f32(pcg3(vec3(bitcast>(p), salt)).x >> 8u) * (1.0 / 16777216.0); +} + +// Value noise with the quintic fade: the cubic's second-derivative seam shows as square plateaus +// on a slowly drifting sky (the WarcraftXL note). +fn value_noise2(p: vec2, salt: u32) -> f32 { + let i = vec2(floor(p)); + let f = fract(p); + let u = f * f * f * (f * (f * 6.0 - 15.0) + 10.0); + let a = hash_cell2(i, salt); + let b = hash_cell2(i + vec2(1, 0), salt); + let c = hash_cell2(i + vec2(0, 1), salt); + let d = hash_cell2(i + vec2(1, 1), salt); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +fn value_noise3(p: vec3) -> f32 { + let i = vec3(floor(p)); + let f = fract(p); + let u = f * f * (3.0 - 2.0 * f); + let n000 = hash33(i).x; + let n100 = hash33(i + vec3(1, 0, 0)).x; + let n010 = hash33(i + vec3(0, 1, 0)).x; + let n110 = hash33(i + vec3(1, 1, 0)).x; + let n001 = hash33(i + vec3(0, 0, 1)).x; + let n101 = hash33(i + vec3(1, 0, 1)).x; + let n011 = hash33(i + vec3(0, 1, 1)).x; + let n111 = hash33(i + vec3(1, 1, 1)).x; + let x00 = mix(n000, n100, u.x); + let x10 = mix(n010, n110, u.x); + let x01 = mix(n001, n101, u.x); + let x11 = mix(n011, n111, u.x); + return mix(mix(x00, x10, u.y), mix(x01, x11, u.y), u.z); +} + +fn fbm3(p: vec3) -> f32 { + var s = 0.0; + var a = 0.5; + var q = p; + for (var o = 0; o < 3; o++) { + s += a * value_noise3(q); + q = q * 2.03 + vec3(1.7, 9.2, 3.4); + a *= 0.5; + } + return s / 0.875; +} + +// ---- S1: the smooth gradient ------------------------------------------------------------------ + +// The reference's ring elevations (degrees), horizon to zenith. +const STOP_ELEV = array(0.0, 1.8, 3.7, 9.8, 16.8, 90.0); + +// Fritsch–Carlson tangent at an interior stop: 0 at a local extremum, else the weighted harmonic +// mean of the two secants, so the curve never overshoots a stop (no halos, no new colours). +fn pchip_tangent(d0: vec3, d1: vec3, h0: f32, h1: f32) -> vec3 { + let w1 = 2.0 * h1 + h0; + let w2 = h1 + 2.0 * h0; + let safe0 = select(d0, vec3(1.0), d0 == vec3(0.0)); + let safe1 = select(d1, vec3(1.0), d1 == vec3(0.0)); + let m = (w1 + w2) / (w1 / safe0 + w2 / safe1); + return select(vec3(0.0), m, d0 * d1 > vec3(0.0)); +} + +// Monotone cubic through the six stops (`y[0]` = fog at 0°, `y[5]` = zenith), linear light. The +// end tangents are 0: flat into the fog band below the horizon and flat over the zenith, so the +// curve is C1 everywhere and the rings leave no Mach bands. +fn smooth_gradient(elev: f32, y: array, 6>) -> vec3 { + if (elev <= 0.0) { + return y[0]; + } + if (elev >= 90.0) { + return y[5]; + } + var yy = y; + var se = STOP_ELEV; + var d: array, 5>; + var h: array; + for (var i = 0; i < 5; i++) { + h[i] = se[i + 1] - se[i]; + d[i] = (yy[i + 1] - yy[i]) / h[i]; + } + var k = 0; + for (var i = 1; i < 5; i++) { + if (elev >= se[i]) { + k = i; + } + } + var m0 = vec3(0.0); + var m1 = vec3(0.0); + if (k > 0) { + m0 = pchip_tangent(d[k - 1], d[k], h[k - 1], h[k]); + } + if (k < 4) { + m1 = pchip_tangent(d[k], d[k + 1], h[k], h[k + 1]); + } + let hk = h[k]; + let t = (elev - se[k]) / hk; + let t2 = t * t; + let t3 = t2 * t; + let h00 = 2.0 * t3 - 3.0 * t2 + 1.0; + let h10 = t3 - 2.0 * t2 + t; + let h01 = -2.0 * t3 + 3.0 * t2; + let h11 = t3 - t2; + return h00 * yy[k] + h10 * hk * m0 + h01 * yy[k + 1] + h11 * hk * m1; +} + +// Triangular dither of ±1 LSB at 8 bits, fixed per pixel (no temporal shimmer). +fn dither_tri(frag: vec2) -> f32 { + let h = hash33(vec3(vec2(frag), 7)); + return (h.x + h.y - 1.0) / 255.0; +} + +// ---- S4: the sun glow ------------------------------------------------------------------------- + +// Henyey–Greenstein phase normalised to 1 toward the sun. +fn hg_norm(c: f32, g: f32) -> f32 { + let g2 = g * g; + let a = 1.0 + g2 - 2.0 * g * c; + let a1 = (1.0 - g) * (1.0 - g); + return pow(a1 / a, 1.5); +} + +// A two-lobe Mie-like halo: a broad aureole plus a tighter core round the disc. +fn sun_glow(dir: vec3, sun: vec3) -> f32 { + let c = dot(dir, sun); + return 0.55 * hg_norm(c, 0.70) + 0.45 * hg_norm(c, 0.93); +} + +// ---- S5: the night sky ------------------------------------------------------------------------ + +// Grid cells per unit radius: one cell spans ~0.3°, so a candidate star every few pixels. +const STAR_GRID: f32 = 180.0; + +// A procedural star field fixed to world directions. Each grid cell holds at most one star (a +// density draw), jittered inside the cell; the eight cells round the sample are tested so no star +// is cut at a cell face. Brightness is a steep power law (few bright, many faint), the footprint +// is at least a pixel wide so a star does not vanish between pixels, and a slow twinkle is +// stronger low in the sky. `px` is the pixel's size in grid units. +fn star_field(dir: vec3, t: f32, px: f32, band: f32) -> vec3 { + let p = dir * STAR_GRID; + let base = floor(p); + let f = p - base; + let o = select(vec3(-1.0), vec3(1.0), f > vec3(0.5)); + let density = 0.035 * (1.0 + 2.5 * band); + var acc = vec3(0.0); + for (var i = 0u; i < 8u; i++) { + let off = vec3(f32(i & 1u), f32((i >> 1u) & 1u), f32((i >> 2u) & 1u)) * o; + let cell = vec3(base + off); + let h = hash33(cell); + if (h.x > density) { + continue; + } + let j = hash33(cell + vec3(7919, 104729, 1299709)); + let centre = normalize(vec3(cell) + 0.5 + (j - 0.5) * 0.9) * STAR_GRID; + let d = length(p - centre); + let mag = pow(h.y, 22.0); + let b = 0.035 + 0.965 * mag; + let sigma = px * (0.55 + 0.7 * mag); + let spot = exp(-d * d / (2.0 * sigma * sigma)); + let tw_amp = 0.18 + 0.3 * (1.0 - clamp(dir.y, 0.0, 1.0)); + let tw = 1.0 + tw_amp * sin(t * (1.3 + 3.1 * j.x) + TAU * j.y) * sin(t * (0.6 + 1.7 * j.z) + TAU * h.z); + // Star colour: mostly white, a few warm and a few blue-white. + let tint = mix(vec3(0.78, 0.86, 1.0), vec3(1.0, 0.88, 0.72), h.z); + acc += tint * (b * spot * tw); + } + return acc; +} + +// The Milky Way's plane in Bevy space (y up): tilted so the band arcs across the dome. +const GALAXY_N: vec3 = vec3(0.40, 0.52, -0.754); +const GALAXY_CORE: vec3 = vec3(-0.62, 0.36, -0.08); + +// Band weight in [0, 1] toward `dir`. +fn galaxy_band(dir: vec3) -> f32 { + let b = dot(dir, normalize(GALAXY_N)); + return exp(-b * b / (2.0 * 0.15 * 0.15)); +} + +// A faint, mottled band with a dark dust lane down its middle and a brighter core. +fn milky_way(dir: vec3, band: f32) -> vec3 { + if (band < 0.01) { + return vec3(0.0); + } + let b = dot(dir, normalize(GALAXY_N)); + let cloud = fbm3(dir * 5.0); + let lane = exp(-(b - 0.02) * (b - 0.02) / (2.0 * 0.03 * 0.03)) * smoothstep(0.35, 0.7, fbm3(dir * 9.0 + 11.0)); + let core = 1.0 + 1.2 * pow(max(dot(dir, normalize(GALAXY_CORE)), 0.0), 4.0); + let v = band * (0.3 + 0.7 * cloud) * (1.0 - 0.75 * lane) * core; + return vec3(0.72, 0.8, 1.0) * v; +} + +// ---- S3: cloud detail ------------------------------------------------------------------------- + +// WarcraftXL's billow blend: fold each octave toward rounded puffs by `cotton` (0.6). +fn billow(v: f32) -> f32 { + return v + (1.0 - abs(2.0 * v - 1.0) - v) * 0.6; +} + +// Billowed fbm, amplitude 0.55 per octave as in WarcraftXL. +fn cloud_billow(p: vec2, octaves: i32) -> f32 { + var s = 0.0; + var a = 1.0; + var tot = 0.0; + var q = p; + for (var o = 0; o < octaves; o++) { + s += a * billow(value_noise2(q, u32(o) * 57u)); + tot += a; + a *= 0.55; + q = q * 2.0 + vec2(3.7, 1.9); + } + return s / tot; +} + +// The detail field in [0, 1] on the sheet: a broad warp twists the domain before the billowed fbm +// is read, which turns thresholded blobs into cauliflower edges. +fn cloud_detail(uv: vec2, t: f32, octaves: i32) -> f32 { + let p = uv * 36.0 + vec2(0.011, 0.0043) * t; + let w = vec2(value_noise2(p * 0.5, 173u), value_noise2(p * 0.5 + 5.3, 219u)) - 0.5; + return cloud_billow(p + w * 2.6, octaves); +} + +// Apply the detail to a base coverage: the edges (mid coverage) are pushed in and out by the +// detail, solid cores and clear sky stay put, so the CPU tile still says where cloud is. +fn cloud_erode(a: f32, n: f32) -> f32 { + let edge = 4.0 * a * (1.0 - a); + return clamp(a + (n - 0.5) * 1.2 * edge, 0.0, 1.0); +} diff --git a/crates/benilla-world/src/sky.rs b/crates/benilla-world/src/sky.rs index 265fd9db..661cca7d 100644 --- a/crates/benilla-world/src/sky.rs +++ b/crates/benilla-world/src/sky.rs @@ -55,6 +55,16 @@ pub struct SkyExt { /// none), `y` the sun azimuth `atan2(sun.z, sun.x)` in radians, `zw` reserved. #[uniform(100)] pub(crate) warp: Vec4, + /// MONKEY (sky): `x` the [`crate::sky_fx::SkyQuality`] tier (0 = Classic, the fields below + /// unused), `y` the sky clock in seconds, `z` the night-sky alpha, `w` the sun-glow strength. + #[uniform(100)] + pub(crate) fx: Vec4, + /// MONKEY (sky): camera to the visible sun (`xyz`). + #[uniform(100)] + pub(crate) sun: Vec4, + /// MONKEY (sky): the glow colour, gamma (`rgb`). + #[uniform(100)] + pub(crate) glow: Vec4, } impl MaterialExtension for SkyExt { @@ -183,6 +193,10 @@ fn setup_sky( sky4: col([0.78, 0.86, 0.95]), fog: col([0.55, 0.72, 0.92]), warp: Vec4::ZERO, // update_sky_colors fills S + sun azimuth each frame + // MONKEY (sky): Classic until update_sky_colors reads the tier. + fx: Vec4::ZERO, + sun: Vec4::Y, + glow: Vec4::ZERO, }, }); commands.spawn(( @@ -242,8 +256,13 @@ fn apply_sky_visibility( } /// Push the time-of-day sky stops and fog colour (`WowLighting`) into the dome material. +#[allow(clippy::too_many_arguments)] fn update_sky_colors( light: Res, + // MONKEY (sky): the tier, the sky clock and the cloud cover over the sun. + quality: Res, + clock: Res, + clouds: Res, dome: Query<&MeshMaterial3d, With>, mut materials: ResMut>, ) { @@ -270,11 +289,16 @@ fn update_sky_colors( 0.0, 0.0, ); + // MONKEY (sky): the Enhanced/High inputs; all zero at Classic so the write gate stays quiet. + let (fx, sun_v, glow) = sky_fx_inputs(&light, *quality, clock.secs, &clouds); benilla_assets::write_gated( &mut materials, &handle.0, |m| { - m.extension.sky0 != sky[0] + m.extension.fx != fx + || m.extension.sun != sun_v + || m.extension.glow != glow + || m.extension.sky0 != sky[0] || m.extension.sky1 != sky[1] || m.extension.sky2 != sky[2] || m.extension.sky3 != sky[3] @@ -290,6 +314,38 @@ fn update_sky_colors( m.extension.sky4 = sky[4]; m.extension.fog = fog; m.extension.warp = warp; + m.extension.fx = fx; + m.extension.sun = sun_v; + m.extension.glow = glow; }, ); } + +/// MONKEY (sky): the dome's Enhanced/High uniforms, `(fx, sun, glow)`; zeros at Classic. +fn sky_fx_inputs( + light: &WowLighting, + quality: crate::sky_fx::SkyQuality, + secs: f32, + clouds: &crate::clouds::CloudCoverage, +) -> (Vec4, Vec4, Vec4) { + if !quality.enhanced() { + return (Vec4::ZERO, Vec4::Y, Vec4::ZERO); + } + let sun = light.celestial_dir.normalize_or(Vec3::Y); + // The cover at the glare point, the same sample the flare's occlusion reads. + let cover = clouds.coverage(sun * 12.0); + let strength = crate::sky_fx::glow_strength(sun.y, cover, light.storm_bcc); + // Warm toward the sun colour, grounded in the fog colour so dusk glows orange, noon pale. + let tint = Vec3::from(light.celestial_tint); + let fog = Vec3::from(light.fog_color); + let glow = fog.lerp(tint, 0.6); + // The night sky follows the star curve, thinned by storm weather. + let night = (light.star_alpha * (1.0 - light.storm_bcc)).clamp(0.0, 1.0); + // Quantized like every other lane here, so an idle frame writes nothing. + let q = |v: f32| benilla_assets::quantize(v, 4096.0); + ( + Vec4::new(f32::from(quality.0), secs, q(night), q(strength)), + Vec4::new(q(sun.x), q(sun.y), q(sun.z), 0.0), + Vec4::new(q(glow.x), q(glow.y), q(glow.z), 0.0), + ) +} diff --git a/crates/benilla-world/src/sky_fx.rs b/crates/benilla-world/src/sky_fx.rs new file mode 100644 index 00000000..42321f58 --- /dev/null +++ b/crates/benilla-world/src/sky_fx.rs @@ -0,0 +1,117 @@ +//! MONKEY (sky): the sky quality tier and what the Enhanced/High sky reads each frame. +//! +//! `skyQuality` 0 is Classic, the reference's image byte for byte: the dome's piecewise-linear +//! gradient and the kernel's cloud texels, untouched. 1 (Enhanced) turns on the smooth gradient, +//! the sun glow and the procedural night sky in `sky.wgsl`; 2 (High) adds the cloud detail and the +//! sun-lit cloud shading in `cloud.wgsl`. The WGSL lives in `shaders/sky_fx.wgsl`. +//! +//! The High cloud detail and sun march (`sky_fx.wgsl`, `cloud.wgsl`, `clouds/layer.rs`): +//! Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module wxl-retail-clouds, Clouds.cpp, Clouds.hpp. + +use bevy::prelude::*; + +/// The sky tier: 0 Classic, 1 Enhanced, 2 High. Written by the app from the `skyQuality` cvar; +/// `$WOW_SKY_QUALITY` pins it for the session (captures). +#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug, Default)] +pub struct SkyQuality(pub u8); + +impl SkyQuality { + /// The highest tier. + pub const MAX: u8 = 2; + + /// The session override, `$WOW_SKY_QUALITY` clamped to the tiers. + pub fn env_override() -> Option { + std::env::var("WOW_SKY_QUALITY") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .map(|v| v.min(Self::MAX)) + } + + pub fn enhanced(self) -> bool { + self.0 >= 1 + } + + pub fn high(self) -> bool { + self.0 >= 2 + } +} + +/// The sky's own animation clock (star twinkle, cloud detail drift), seconds wrapped to an hour so +/// the shader's `sin` keeps its precision. Frozen in capture mode, at `$WOW_CAPTURE_SKY_T` or 0. +#[derive(Resource, Default)] +pub struct SkyClock { + pub secs: f32, + frozen: Option, +} + +/// How strongly the glow shows: a broad halo at `GLOW_GAIN` × the sun colour at the sun itself. +pub(crate) const GLOW_GAIN: f32 = 0.32; + +/// The glow's day fade: full once the sun clears the horizon, gone a few degrees under it. +pub(crate) fn glow_day_fade(sun_y: f32) -> f32 { + let t = ((sun_y + 0.10) / 0.16).clamp(0.0, 1.0); + t * t * (3.0 - 2.0 * t) +} + +/// The glow strength: the day fade, dimmed by the cloud cover over the sun (the same coverage the +/// glare's occlusion reads) and by storm weather. +pub(crate) fn glow_strength(sun_y: f32, cover_at_sun: f32, bcc: f32) -> f32 { + GLOW_GAIN * glow_day_fade(sun_y) * (1.0 - 0.8 * cover_at_sun.clamp(0.0, 1.0)) + * (1.0 - 0.85 * bcc.clamp(0.0, 1.0)) +} + +pub(crate) struct SkyFxPlugin; + +impl Plugin for SkyFxPlugin { + fn build(&self, app: &mut App) { + // The library `sky.wgsl` and `cloud.wgsl` import; its embedded path is served by + // `shaders::plugin`, this keeps it loaded so the import resolves. + let lib: Handle = + bevy::asset::load_embedded_asset!(app, "shaders/sky_fx.wgsl"); + std::mem::forget(lib); + let frozen = std::env::var_os("WOW_CAPTURE").map(|_| { + std::env::var("WOW_CAPTURE_SKY_T") + .ok() + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(0.0) + }); + app.insert_resource(SkyClock { + secs: frozen.unwrap_or(0.0), + frozen, + }); + let quality = SkyQuality(SkyQuality::env_override().unwrap_or(0)); + app.insert_resource(quality) + .add_systems(Update, tick_sky_clock); + } +} + +fn tick_sky_clock(time: Res + + + + + + + + OptionsRow_OnLoad(self, "foliageWind", + BENILLA_ADVGFX.foliageWind, + "BENILLA_TOOLTIP_FOLIAGE_WIND") + OptionsDropdownRow_Setup(self, { + { text = BENILLA_ADVGFX.qOff, value = "0" }, + { text = BENILLA_ADVGFX.windGrass, value = "1" }, + { text = BENILLA_ADVGFX.windTrees, value = "2" }, + }) + + + + + + + + + OptionsRow_OnLoad(self, "fogModel", + BENILLA_ADVGFX.fogModel, + "BENILLA_TOOLTIP_FOG_MODEL") + + + + + + + + + OptionsRow_OnLoad(self, "rainSurfaces", + BENILLA_ADVGFX.rainSurfaces, + "BENILLA_TOOLTIP_RAIN_SURFACES") + + + + + + + + + OptionsRow_OnLoad(self, "lampFog", BENILLA_ADVGFX.lampFog, + "BENILLA_TOOLTIP_LAMP_FOG") + OptionsDropdownRow_Setup(self, { + { text = BENILLA_ADVGFX.fogOff, value = "0" }, + { text = BENILLA_ADVGFX.qLow, value = "1" }, + { text = BENILLA_ADVGFX.qHigh, value = "2" }, + }) + + + + + + + + + OptionsRow_OnLoad(self, "daylightWindowSplit", + BENILLA_ADVGFX.daylightWindowSplit, + "BENILLA_TOOLTIP_DAYLIGHT_WINDOW_SPLIT") + + + + + OptionsRow_OnLoad(self, "graphicsQuality", + BENILLA_ADVGFX.graphicsQuality, + "BENILLA_TOOLTIP_GRAPHICS_QUALITY") + self.preset = 1 + OptionsDropdownRow_Setup(self, { + { text = BENILLA_ADVGFX.pClassic, value = "Classic" }, + { text = BENILLA_ADVGFX.qLow, value = "Low" }, + { text = BENILLA_ADVGFX.qMedium, value = "Medium" }, + { text = BENILLA_ADVGFX.qHigh, value = "High" }, + { text = BENILLA_ADVGFX.qUltra, value = "Ultra" }, + { text = BENILLA_ADVGFX.qCustom, value = "Custom" }, + }) + + + + + + + + + + OptionsRow_OnLoad(self, "farclip", + BENILLA_ADVGFX.renderDistance, + "BENILLA_TOOLTIP_RENDER_DISTANCE") + OptionsSliderRow_Setup(self, 177, 1497, 60, "yd") + + + + + + + -- The preset ladder. This row writes ONE CVar and -- nothing else: the host's own table @@ -2701,6 +2804,7 @@ { text = BENILLA_ADVGFX.qLow, value = "Low" }, { text = BENILLA_ADVGFX.qMedium, value = "Medium" }, { text = BENILLA_ADVGFX.qHigh, value = "High" }, + { text = BENILLA_ADVGFX.qUltra, value = "Ultra" }, { text = BENILLA_ADVGFX.qCustom, value = "Custom" }, }) diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index 51fde75e..2131ce86 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -916,9 +916,22 @@ pub(crate) const REGISTERED: &[Registered] = &[ "lightingQuality", "High", "benilla's own: the preset ladder over the dynamic light + shadow rows — Off / Low / \ - Medium / High, or Custom when the members match none of them; the reference has no \ + Medium / High / Ultra, or Custom when the members match none of them; the reference has no \ realtime light or shadow system to preset", ), + // MONKEY (presets): the Graphics Preset — `lightingQuality`'s posture one level up, a NAME + // for every graphics row at once ([`GRAPHICS_PRESETS`]), re-derived every frame. "High" is + // what a player with no saved preset boots into: [`Cvars::seed_graphics_preset`] writes the + // High column over every row their `config.toml` does not carry, so the rows themselves keep + // their per-lane defaults (Classic-leaning, and `farclip` the reference's 350) and a capture + // or a test, which never seeds, keeps them too. + ours( + "graphicsQuality", + "High", + "benilla's own: the preset ladder over every graphics row — Classic / Low / Medium / High \ + / Ultra, or Custom when the rows match none of them; the reference's options have no \ + such preset", + ), // MONKEY (volumetric fog): saved live tier; capture override stays session-only. ours("volumetricFog", "1", "benilla's own: near-field volumetric fog, 0 Off / 1 Low / 2 High"), // MONKEY (p0 skyDither): the FFXGlow combine's deband dither (was env WOW_DITHER only). @@ -1733,6 +1746,9 @@ impl Cvars { if name.eq_ignore_ascii_case("lightingQuality") { apply_lighting_preset(self, value); } + if name.eq_ignore_ascii_case("graphicsQuality") { + apply_graphics_preset(self, value); + } outcome } @@ -1744,9 +1760,38 @@ impl Cvars { if name.eq_ignore_ascii_case("lightingQuality") { apply_lighting_preset(self, value); } + if name.eq_ignore_ascii_case("graphicsQuality") { + apply_graphics_preset(self, value); + } outcome } + /// MONKEY (presets): **the first boot's Graphics Preset** — a player whose `config.toml` + /// names no `graphicsQuality` (a new player, or one from before the ladder) gets the + /// [`GRAPHICS_DEFAULT`] column written over every governed row the file does not carry and the + /// session does not own. Rows the file carries are the player's and stay; the lighting rung + /// is left alone, because High on that ladder IS the registered defaults + /// (`the_high_preset_is_the_registered_defaults`). Returns how many rows moved. + pub(crate) fn seed_graphics_preset(&mut self) -> usize { + let carried = |cvars: &Self, k: &str| { + cvars.file.keys().any(|f| f.eq_ignore_ascii_case(k)) || cvars.is_session_owned(k) + }; + if carried(self, "graphicsQuality") { + return 0; + } + let col = graphics_column(GRAPHICS_DEFAULT).expect("the default is a rung"); + let mut moved = 0; + for (k, values) in GRAPHICS_PRESETS { + if k.eq_ignore_ascii_case("lightingQuality") || carried(self, k) { + continue; + } + if self.set(k, values[col]) == SetOutcome::Changed { + moved += 1; + } + } + moved + } + /// **The table follows a value the engine already applied** — a mirror, not a write: the /// applied value moves, the config dirties, the VM's mirror learns it, and **no observer /// fires**, because the knob is already there. For a second registered spelling of one knob @@ -2166,6 +2211,35 @@ pub(crate) const LIGHTING_PRESETS: &[(&str, &[(&str, &str)])] = &[ ("foliageWind", "2"), ], ), + // MONKEY (presets): High plus every lane at its maximum — the 4096 sun map, all sixteen + // resident torch maps with eight of them tracking movers, mirror-reflection water and the + // High fog march. Disjoint from High on `shadowMapSize`, so derivation never confuses them. + ( + "Ultra", + &[ + ("characterShadows", "1"), + ("worldShadows", "1"), + ("shadowMapSize", "4096"), + ("interiorLight", "1"), + ("interiorShadows", "1"), + ("interiorShadowCasters", "16"), + ("interiorShadowDynamic", "8"), + ("exteriorShadows", "1"), + ("spellLightGain", "1"), + ("waterQuality", "2"), + ("volumetricFog", "2"), + ("bloom", "2"), + ("sunShafts", "1"), + ("colorGrading", "1"), + ("lavaLightGain", "1"), + ("fireLightGain", "1"), + ("nightGain", "0.45"), + ("interiorGain", "0.5"), + ("moonShadowStrength", "0.35"), + ("fireFlicker", "1"), + ("foliageWind", "2"), + ], + ), ]; /// What the ladder shows when the members match no preset. Not a preset: selecting it writes @@ -2219,6 +2293,82 @@ fn lighting_quality(cvars: &mut Cvars) { let derived = derive_lighting_quality(cvars); // A derived label is a mirror, never another request to apply a preset. cvars.mirror("lightingQuality", derived); + // MONKEY (presets): after the lighting label, which the graphics ladder reads as a member. + let graphics = derive_graphics_quality(cvars); + cvars.mirror("graphicsQuality", graphics); +} + +// ─── MONKEY (presets): the Graphics Preset ladder ───────────────────────────────────────────── + +/// The Graphics Preset's rungs, in the order [`derive_graphics_quality`] tries them and the +/// column order of [`GRAPHICS_PRESETS`]. +pub(crate) const GRAPHICS_PRESET_NAMES: [&str; 5] = ["Classic", "Low", "Medium", "High", "Ultra"]; + +/// The rung a player with no saved preset boots into ([`Cvars::seed_graphics_preset`]). +pub(crate) const GRAPHICS_DEFAULT: &str = "High"; + +/// **The one place the Graphics Preset is written down**: one line per governed row, one column +/// per rung of [`GRAPHICS_PRESET_NAMES`]. `lightingQuality` is itself a row — writing its rung +/// writes [`LIGHTING_PRESETS`]' members — so every lighting, water, fog, post and wind row the +/// lighting ladder owns is governed here without a second copy. The other rows are disjoint from +/// that ladder, which is what lets both labels be right at once. +/// +/// Classic is the reference client: every lane off, `farclip` at its registered 350. High is +/// each lane's documented High (LIGHTING.md, WATER.md); Ultra is High at every maximum, and the +/// only rung past the reference's 777 yd (`FARCLIP_RANGE`); Low and Medium keep the practically +/// free lanes (sky, dither, modern fog, wet surfaces) and leave the costly ones to High. +/// +/// A row that lands later is ONE line here; the three pending ones sit commented with their +/// intended columns until the rows they name are registered (an unregistered member would never +/// match, and the ladder would read Custom forever). +#[rustfmt::skip] +pub(crate) const GRAPHICS_PRESETS: &[(&str, [&str; 5])] = &[ + // Classic Low Medium High Ultra + ("lightingQuality", ["Off", "Low", "Medium", "High", "Ultra"]), + ("farclip", ["350", "350", "477", "777", "1497"]), + ("skyQuality", ["0", "1", "1", "2", "2"]), + ("skyDither", ["0", "1", "1", "1", "1"]), + ("fogModel", ["0", "1", "1", "1", "1"]), + ("rainSurfaces", ["0", "1", "1", "1", "1"]), + ("torchTerrainShadows", ["0", "0", "0", "1", "1"]), + ("daylightWindowSplit", ["0", "1", "1", "1", "1"]), + // TODO(presets): uncomment each as its row lands from the parallel merge. + // ("ambientOcclusion", ["0", "0", "1", "2", "2"]), + // ("zoneSkyboxes", ["0", "1", "1", "1", "1"]), + // ("lampFog", ["0", "0", "1", "2", "2"]), +]; + +/// The column of a rung, matched case-insensitively; `None` for `Custom` or anything else. +fn graphics_column(name: &str) -> Option { + GRAPHICS_PRESET_NAMES + .iter() + .position(|n| n.eq_ignore_ascii_case(name)) +} + +/// The rung every governed row currently spells, or [`LIGHTING_CUSTOM`] (the one "Custom" both +/// ladders show). Reads `lightingQuality`'s DERIVED label, so run it after that is mirrored. +pub(crate) fn derive_graphics_quality(cvars: &Cvars) -> &'static str { + for (col, name) in GRAPHICS_PRESET_NAMES.iter().enumerate() { + let matched = GRAPHICS_PRESETS + .iter() + .all(|(k, v)| cvars.get(k).is_some_and(|live| same_value(live, v[col]))); + if matched { + return name; + } + } + LIGHTING_CUSTOM +} + +/// Write one rung's column through [`Cvars::set`]; `false` (and nothing written) for an unknown +/// name, `Custom` included. +pub(crate) fn apply_graphics_preset(cvars: &mut Cvars, name: &str) -> bool { + let Some(col) = graphics_column(name) else { + return false; + }; + for (k, values) in GRAPHICS_PRESETS { + cvars.set(k, values[col]); + } + true } /// **What the environment took for this session, and what it set it to** — read off the knobs @@ -2313,6 +2463,11 @@ fn session_values(world: &World) -> Vec<(&'static str, Option)> { fn load_config(world: &mut World) { let session = session_values(world); let stored = stored_config(); + let stored_kind = match &stored { + StoredConfig::Absent => StoredKind::Absent, + StoredConfig::Table(_) => StoredKind::Table, + StoredConfig::Bad(_) => StoredKind::Bad, + }; let events = { let mut cvars = world.resource_mut::(); for (name, value) in session { @@ -2334,6 +2489,11 @@ fn load_config(world: &mut World) { } StoredConfig::Table(table) => cvars.load_file(table), } + // MONKEY (presets): a player's own run (never a capture, never a malformed file) boots + // into the default Graphics Preset over whatever its file leaves unsaid. + if seeds_graphics_preset(&stored_kind) { + cvars.seed_graphics_preset(); + } cvars.take_events() }; for event in events { @@ -2341,6 +2501,22 @@ fn load_config(world: &mut World) { } } +/// MONKEY (presets): which [`StoredConfig`] arm a boot took, kept past the table's move. +enum StoredKind { + Absent, + Table, + Bad, +} + +/// MONKEY (presets): whether this boot seeds the default Graphics Preset — a run that reads and +/// saves a player's `config.toml` and found it absent or well-formed. A capture (hermetic, or on +/// an explicit fixture) keeps the registered defaults so its A/B stays exact, and a malformed +/// file is left for the player rather than papered over. +fn seeds_graphics_preset(kind: &StoredKind) -> bool { + let players_file = std::env::var_os("WOW_CAPTURE").is_none() && config_read_path().is_some(); + players_file && matches!(kind, StoredKind::Absent | StoredKind::Table) +} + /// What the one read of `config.toml` found. enum StoredConfig { /// No file, no install, or a capture without an explicit fixture — use registered defaults. @@ -3108,6 +3284,8 @@ mod tests { for member in ["fireLightGain", "waterQuality", "volumetricFog", "lavaLightGain", "nightGain", "interiorGain"] { let expected = if *name == "Off" { if matches!(member, "fireLightGain" | "waterQuality" | "volumetricFog" | "lavaLightGain") { "0" } else { "1.0" } + } else if *name == "Ultra" && matches!(member, "waterQuality" | "volumetricFog") { + "2" // MONKEY (presets): Ultra's two maxima over the defaults. } else { cvars.default_of(member).unwrap() }; @@ -3192,10 +3370,212 @@ mod tests { let mut cvars = fresh_registry(); apply_lighting_preset(&mut cvars, "Medium"); assert!(!apply_lighting_preset(&mut cvars, LIGHTING_CUSTOM)); - assert!(!apply_lighting_preset(&mut cvars, "Ultra")); + assert!(!apply_lighting_preset(&mut cvars, "Epic")); assert_eq!(derive_lighting_quality(&cvars), "Medium"); } + // ── MONKEY (presets): the Graphics Preset ladder ────────────────────────────────────────── + + /// Every governed row is registered and names no row the lighting ladder already owns — the + /// disjointness that lets both labels be right at once. + #[test] + fn graphics_rows_are_registered_and_disjoint_from_the_lighting_ladder() { + assert_eq!(GRAPHICS_PRESETS.len(), 8, "add the row count with the row"); + let cvars = fresh_registry(); + for (k, values) in GRAPHICS_PRESETS { + assert!(cvars.get(k).is_some(), "{k}: not registered"); + for (rung, members) in LIGHTING_PRESETS { + assert!( + members.iter().all(|(m, _)| !m.eq_ignore_ascii_case(k)), + "{k} is also a member of lighting {rung}" + ); + } + if *k == "lightingQuality" { + for v in values { + assert!( + LIGHTING_PRESETS.iter().any(|(n, _)| n == v), + "{v}: a lighting rung" + ); + } + } + } + // The rungs are distinct columns, so no two can derive to the same name. + for a in 0..GRAPHICS_PRESET_NAMES.len() { + for b in a + 1..GRAPHICS_PRESET_NAMES.len() { + assert!( + GRAPHICS_PRESETS + .iter() + .any(|(_, v)| !same_value(v[a], v[b])), + "{} and {} are the same column", + GRAPHICS_PRESET_NAMES[a], + GRAPHICS_PRESET_NAMES[b] + ); + } + } + } + + /// **Preset → rows → the same preset**, from every rung to every rung, through the ordinary + /// `set` path — with the two rows no preset decides left where the player put them. + #[test] + fn every_graphics_preset_derives_back_to_its_own_name() { + for from in GRAPHICS_PRESET_NAMES { + for name in GRAPHICS_PRESET_NAMES { + let mut cvars = fresh_registry(); + cvars.set("graphicsQuality", from); + cvars.set("shadowDistance", "120"); + cvars.set("interiorShadowSoft", "2.5"); + cvars.set("graphicsQuality", name); + lighting_quality(&mut cvars); + assert_eq!(derive_graphics_quality(&cvars), name, "{from} -> {name}"); + assert_eq!(cvars.get("graphicsQuality"), Some(name)); + let col = graphics_column(name).unwrap(); + assert_eq!( + cvars.get("lightingQuality"), + Some(GRAPHICS_PRESETS[0].1[col]) + ); + assert_eq!(cvars.get("shadowDistance"), Some("120")); + assert_eq!(cvars.get("interiorShadowSoft"), Some("2.5")); + } + } + } + + /// Classic is the reference client: the lighting ladder's Off, every programme lane off and + /// `farclip` at the reference's registered 350. + #[test] + fn the_classic_preset_is_every_lane_off_at_the_reference_view_distance() { + let mut cvars = fresh_registry(); + assert!(apply_graphics_preset(&mut cvars, "Classic")); + assert_eq!(cvars.get("lightingQuality"), Some("Off")); + assert_eq!(cvars.get("farclip"), cvars.default_of("farclip")); + for (k, values) in GRAPHICS_PRESETS { + if !matches!(*k, "lightingQuality" | "farclip") { + assert!(same_value(values[0], "0"), "{k}: Classic leaves it on"); + } + } + // Ultra is the one rung past the reference's 777, and inside the extended clamp. + let ultra = GRAPHICS_PRESETS + .iter() + .find(|(k, _)| *k == "farclip") + .unwrap() + .1[4]; + let ultra: f32 = ultra.parse().unwrap(); + assert!(ultra > 777.0 && FARCLIP_RANGE.contains(&ultra)); + } + + /// **Editing one governed row is Custom** — for every rung and every row of it, the lighting + /// rung included (moved by one of ITS members, as the page's lighting rows would). + #[test] + fn one_changed_graphics_row_derives_custom() { + for name in GRAPHICS_PRESET_NAMES { + let col = graphics_column(name).unwrap(); + for (k, values) in GRAPHICS_PRESETS { + let mut cvars = fresh_registry(); + apply_graphics_preset(&mut cvars, name); + let (row, moved) = if *k == "lightingQuality" { + let to = if values[col] == "Off" { "1" } else { "0" }; + ("characterShadows", to.to_string()) + } else { + let v: f32 = values[col].parse().unwrap(); + let to = if *k == "farclip" { + v + 60.0 + } else if v == 0.0 { + 1.0 + } else { + 0.0 + }; + (*k, to.to_string()) + }; + assert_eq!(cvars.set(row, &moved), SetOutcome::Changed, "{name}/{row}"); + lighting_quality(&mut cvars); + assert_eq!( + cvars.get("graphicsQuality"), + Some(LIGHTING_CUSTOM), + "{name}/{row}" + ); + // …and picking the rung again puts every row back. + assert!(apply_graphics_preset(&mut cvars, name)); + lighting_quality(&mut cvars); + assert_eq!( + cvars.get("graphicsQuality"), + Some(name), + "{name}/{row} re-pick" + ); + } + } + let mut cvars = fresh_registry(); + assert!(!apply_graphics_preset(&mut cvars, LIGHTING_CUSTOM)); + assert!(!apply_graphics_preset(&mut cvars, "Epic")); + } + + /// **A new player boots into High**: the seed writes the High column over a registry with no + /// file, and the registered default of the label agrees, so it never reaches `config.toml`. + #[test] + fn a_fresh_player_is_seeded_to_the_high_graphics_preset() { + assert_eq!( + fresh_registry().default_of("graphicsQuality"), + Some(GRAPHICS_DEFAULT) + ); + let mut cvars = fresh_registry(); + assert!(cvars.seed_graphics_preset() > 0); + lighting_quality(&mut cvars); + assert_eq!(cvars.get("graphicsQuality"), Some("High")); + assert_eq!(cvars.get("lightingQuality"), Some("High")); + assert_eq!(cvars.get("farclip"), Some("777")); + assert!( + !cvars.compose().contains_key("graphicsQuality"), + "the default label is not saved" + ); + // A second boot over what the first one saved seeds nothing. + let mut again = fresh_registry(); + again.load_file(cvars.compose()); + assert_eq!(again.seed_graphics_preset(), 0); + lighting_quality(&mut again); + assert_eq!(again.get("graphicsQuality"), Some("High")); + } + + /// The seed never overrides the player: a saved preset stops it outright, a saved row keeps + /// its value, and a session-owned row (an env lever) is left to the session. + #[test] + fn the_graphics_seed_leaves_saved_and_session_rows_alone() { + let mut saved = fresh_registry(); + saved.load_file(BTreeMap::from([( + "graphicsQuality".into(), + "Classic".into(), + )])); + assert_eq!(saved.seed_graphics_preset(), 0); + assert_eq!(saved.get("skyQuality"), saved.default_of("skyQuality")); + + let mut row = fresh_registry(); + row.load_file(BTreeMap::from([("skyQuality".into(), "1".into())])); + row.own_for_session("farclip", Some("500")); + row.seed_graphics_preset(); + lighting_quality(&mut row); + assert_eq!(row.get("skyQuality"), Some("1")); + assert_eq!(row.get("farclip"), Some("500")); + assert_eq!(row.get("fogModel"), Some("1")); + assert_eq!(row.get("graphicsQuality"), Some(LIGHTING_CUSTOM)); + } + + /// Ultra's `farclip` survives the real observer (the extended clamp), and every governed + /// row's rung values do too. + #[test] + fn graphics_rows_pass_their_observers_unclamped() { + let mut app = cvar_app(); + for (col, name) in GRAPHICS_PRESET_NAMES.iter().enumerate() { + let farclip = GRAPHICS_PRESETS + .iter() + .find(|(k, _)| *k == "farclip") + .unwrap() + .1[col]; + apply(&mut app, "farclip", farclip); + assert_eq!( + res::(&app).farclip, + farclip.parse::().unwrap(), + "{name}" + ); + } + } + /// Every member every rung names is a REGISTERED row — a typo'd member would otherwise make /// its whole preset underivable (a `cvars.get` that answers `None` never matches), and the /// only symptom would be a dropdown stuck on `Custom`. @@ -3722,7 +4102,16 @@ mod tests { assert!(text.contains("MusicVolume = \"0.75\""), "{text}"); assert!(!text.contains("MasterVolume"), "defaults stay out:\n{text}"); let back: LocalConfig = toml::from_str(&text).unwrap(); - assert_eq!(back.cvars.len(), 1, "a diff, not a dump: {text}"); + // MONKEY (presets): the file named no `graphicsQuality`, so the first boot seeded the + // High rungs over the governed rows; those are the only other entries, and the label + // itself, at its default, stays out. + let player: Vec<&String> = back + .cvars + .keys() + .filter(|k| !GRAPHICS_PRESETS.iter().any(|(g, _)| g.eq_ignore_ascii_case(k))) + .collect(); + assert_eq!(player.len(), 1, "a diff, not a dump: {text}"); + assert!(!text.contains("graphicsQuality"), "{text}"); std::fs::remove_dir_all(&tmp).ok(); } @@ -4096,7 +4485,14 @@ mod tests { strings.sort_unstable(); // the list is the claim, not where the rows sit in the table assert_eq!( strings, - vec!["gxApi", "gxResolution", "lightingQuality", "realmList", "realmName"] + vec![ + "graphicsQuality", + "gxApi", + "gxResolution", + "lightingQuality", + "realmList", + "realmName" + ] ); let default_of = |name: &str| { REGISTERED @@ -4413,6 +4809,11 @@ mod tests { "GameTooltip's binding-line gate, stock and pfUI (1316)", ), ("gxApi", "pfUI's system tooltip names the backend (2151)"), + ( + "graphicsQuality", + "MONKEY (presets): the Advanced Graphics page's Graphics Preset row; the host \ + writes and derives it here in cvars.rs", + ), ( "useUiScale", "UIOptionsFrame.lua and OptionsFrame.lua branch on it to gate the uiScale slider", diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 41062c64..8c2863c2 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -2042,7 +2042,7 @@ fn volumetric_fog_dropdown_is_localised_and_live() { s.run(&format!("function GetLocale() return '{locale}' end")).unwrap(); let mut s = harness_on(s); s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()").unwrap(); - assert_eq!(s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[3]").unwrap(), "RowVolumetricFog"); + assert_eq!(s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[5]").unwrap(), "RowVolumetricFog"); assert_eq!(s.eval::(&format!("return {ADVGFX}RowVolumetricFogLabel:GetText()")).unwrap(), title); assert_eq!(s.eval::(&format!("return {ADVGFX}RowVolumetricFogDropdownText:GetText()")).unwrap(), labels[1]); let _ = s.take_cvar_changes(); @@ -2067,9 +2067,10 @@ fn water_quality_writes_numeric_tiers_with_localised_labels() { let mut s = harness_on(s); s.run("ShowUIPanel(BenillaOptionsFrame) BenillaOptionsFrameCategoryListRowAdvancedGraphics:Click()").unwrap(); // MONKEY (volumetric fog): account for the atmosphere row after water. - assert_eq!(s.eval::("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 18); - assert_eq!(s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[2]").unwrap(), "RowWaterQuality"); - assert_eq!(s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[16]").unwrap(), "RowLavaGlow"); + // MONKEY (presets): Graphics Preset and Render Distance lead the page: 27 -> 29. + assert_eq!(s.eval::("return table.getn(OPTIONS_PAGE_ROWS.AdvancedGraphics)").unwrap(), 29); + assert_eq!(s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[4]").unwrap(), "RowWaterQuality"); + assert_eq!(s.eval::("return OPTIONS_PAGE_ROWS.AdvancedGraphics[23]").unwrap(), "RowLavaGlow"); assert_eq!(s.eval::(&format!("return {ADVGFX}RowWaterQualityDropdownText:GetText()")).unwrap(), labels[1]); assert_eq!(s.eval::(&format!("return {ADVGFX}RowLavaGlowLabel:GetText()")).unwrap(), lava_label); assert!(s.eval::("return BENILLA_TOOLTIP_WATER_QUALITY == BENILLA_ADVGFX.tips.WATER_QUALITY and BENILLA_TOOLTIP_LAVA_GLOW == BENILLA_ADVGFX.tips.LAVA_GLOW").unwrap()); @@ -3033,6 +3034,15 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() { "BENILLA_TOOLTIP_LIGHTING_QUALITY", "AdvancedGraphicsRowLightingQuality", ), + // MONKEY (presets) + ( + "BENILLA_TOOLTIP_GRAPHICS_QUALITY", + "AdvancedGraphicsRowGraphicsQuality", + ), + ( + "BENILLA_TOOLTIP_RENDER_DISTANCE", + "AdvancedGraphicsRowRenderDistance", + ), ( "BENILLA_TOOLTIP_SHADOW_RESOLUTION", "AdvancedGraphicsRowShadowResolution", @@ -3143,7 +3153,8 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() { // changing pages does not change this count, only its entry in BENILLA_OWNED. 81 -> 93. // Water Quality and Lava Glow add two more: 93 -> 95. // MONKEY (daylight: terrain torch casters): Terrain Blocks Torchlight. 95 -> 96. - assert_eq!(checked, 96, "every tipped row carries a live key"); + // MONKEY (presets): Graphics Preset and Render Distance. +2. + assert_eq!(checked, 98, "every tipped row carries a live key"); assert_eq!( untipped, vec![ From 552a0b61c6e6c70f2b46d0134913e3bf9af12aa7 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 19:09:34 +0100 Subject: [PATCH 78/87] MONKEY (integration): fix the programme's test failures (warm names, shader embed, dev door, CRLF/path-separator test bugs, float compare, tooltip newlines) and declare the missing system orders - ssao.wgsl registered in shaders::plugin and loaded by its embedded path - wind/wetness/fog-model MonkeyFrame writers ordered; viewer publish before its readers; sky clock + sky tier before LightingConsumeSet; zone skybox chain joins the consume set; grass benders read GlobalTransform - ambiguity ceiling re-measured with the reason (MatAnimTable allocator + exclusive-system pairs) - verb_event_gate: Windows separators; AddSkillUp/RemoveSkillUp GAP rows - ui_session portrait test: CRLF checkout - spell envelope fuse assertion: f32 tolerance - daylightWindowSplit tooltip: escaped \n (raw line breaks broke the Lua chunk) Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/assets/ui/OptionsFrame.xml | 8 ++---- .../src/entities/spell_fx/lifecycle.rs | 3 +- crates/benilla-app/src/game_plugins.rs | 10 ++++++- crates/benilla-app/src/player.rs | 6 +++- crates/benilla-app/src/shaders.rs | 4 ++- crates/benilla-app/src/sky_quality.rs | 6 +++- crates/benilla-app/src/ssao.rs | 7 +++-- .../src/ui_script/verb_event_gate.rs | 19 +++++++++++-- crates/benilla-app/src/ui_session.rs | 3 +- crates/benilla-world/src/sky_fx.rs | 3 +- crates/benilla-world/src/skybox.rs | 2 ++ crates/benilla-world/src/weather/wetness.rs | 10 ++++++- crates/benilla-world/src/wind/mod.rs | 28 +++++++++++++++---- 13 files changed, 85 insertions(+), 24 deletions(-) diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index d9ad6c90..77843a0c 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -693,9 +693,7 @@ DAYLIGHT_WINDOW_SPLIT = "Large city buildings take daylight through each window on its own instead of " .. "treating a whole wall of windows as one opening, so halls like the cathedral " - .. "catch the sun pane by pane. - -Applies to buildings loaded after the change. " + .. "catch the sun pane by pane.\n\nApplies to buildings loaded after the change. " .. "Off is the earlier look.", -- MONKEY (lampfog) LAMP_FOG = @@ -921,9 +919,7 @@ Applies to buildings loaded after the change. " DAYLIGHT_WINDOW_SPLIT = "Большие городские здания пропускают дневной свет через каждое окно отдельно, " .. "а не через всю стену окон как один проём, поэтому залы вроде собора " - .. "освещаются солнцем окно за окном. - -Действует для зданий, загруженных после " + .. "освещаются солнцем окно за окном.\n\nДействует для зданий, загруженных после " .. "изменения. Выключено — прежний вид.", -- MONKEY (lampfog) LAMP_FOG = diff --git a/crates/benilla-app/src/entities/spell_fx/lifecycle.rs b/crates/benilla-app/src/entities/spell_fx/lifecycle.rs index 3f4fbbfa..01e2c3ca 100644 --- a/crates/benilla-app/src/entities/spell_fx/lifecycle.rs +++ b/crates/benilla-app/src/entities/spell_fx/lifecycle.rs @@ -639,7 +639,8 @@ mod tests { // The ONSET: a firework's fuse. Dark for the whole flight, up at the detonation. let fuse = || SpellLight::new(1.0, 1.5, SpellLightMode::Burst { span: 0.6 }); assert_eq!(at(fuse(), 1.4), 0.0); - assert_eq!(at(fuse(), 1.5 + SPELL_LIGHT_RAMP), 1.0); + // MONKEY (integration): (1.5 + ramp) - 1.5 is not exactly `ramp` in f32. + assert!((at(fuse(), 1.5 + SPELL_LIGHT_RAMP) - 1.0).abs() < 1e-6); // The reap fade: a kit light goes dark on its instance's `FxDecay`, quicker than the // model's own Decay sequence — the aura is over the moment the server said so. diff --git a/crates/benilla-app/src/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index 65d3b818..06ab6b77 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -1089,7 +1089,15 @@ pub(crate) mod schedule_tests { /// the net drain's. Same tree, the class dropped, nothing else moved: 5,052 + 556. /// **5,554 (decision 2345)** — lowered: the TAB scan stopped reading `Visibility` (the draw /// election's verdict), which ended its pairs with every `Visibility` writer. - const UPDATE_ACTIONABLE_CEILING: usize = 5_554; + /// **5,582 (MONKEY integration round 3, graphics programme)** — measured on the merged tree + /// after declaring the orders the lanes had left out (wind → wetness → fog-model writers of + /// `MonkeyFrame`; the viewer publish before its new readers; the sky clock and sky tier before + /// the consume set; the grass benders on `GlobalTransform` and after the camera-pose copy). + /// What remains, read off the dump: 13 pairs of `skybox::animate_skyboxes` against the + /// `MatAnimTable` allocators (attach/spawn/doodad systems) — the allocator case of 2300, the + /// skybox writes only the rows it owns — and one pair each of the new systems against the two + /// exclusive systems (`finish_colliders`, `apply_net_updates`), the 2343 class. + const UPDATE_ACTIONABLE_CEILING: usize = 5_582; const UPDATE_ACTIONABLE_SLACK: usize = 40; fn ratchet(what: &str, n: usize, ceiling: usize, slack: usize) { diff --git a/crates/benilla-app/src/player.rs b/crates/benilla-app/src/player.rs index 3f69801e..4eb4830e 100644 --- a/crates/benilla-app/src/player.rs +++ b/crates/benilla-app/src/player.rs @@ -328,7 +328,11 @@ impl Plugin for PlayerPlugin { Update, (world_focus::publish_viewer, world_focus::publish_view_focus) .after(benilla_world::schedule::WorldStage::Input) - .before(benilla_world::schedule::WorldStage::Stream), + .before(benilla_world::schedule::WorldStage::Stream) + // MONKEY (integration): the `Viewer` readers the programme added (grass benders, + // fog model, zone skybox) run after this frame's publish. + .before(benilla_world::wind::WindTick) + .before(benilla_world::lighting::LightingResolveSet), ) .add_systems( Update, diff --git a/crates/benilla-app/src/shaders.rs b/crates/benilla-app/src/shaders.rs index 55ab58a5..26f0fb24 100644 --- a/crates/benilla-app/src/shaders.rs +++ b/crates/benilla-app/src/shaders.rs @@ -26,6 +26,8 @@ pub(crate) fn plugin(app: &mut App) { bevy::asset::embedded_asset!(app, "shaders/ui_slice_gamma.wgsl"); bevy::asset::embedded_asset!(app, "shaders/shadow_caster.wgsl"); bevy::asset::embedded_asset!(app, "shaders/shadow_caster_cutout_prepass.wgsl"); + // MONKEY (ao): the contact-shadow pass. + bevy::asset::embedded_asset!(app, "shaders/ssao.wgsl"); } #[cfg(test)] @@ -61,6 +63,6 @@ mod tests { `shaders::plugin`, or was registered from a file that is not directly under src/" ); } - assert_eq!(found, 7, "the game's shader set changed size"); + assert_eq!(found, 8, "the game's shader set changed size"); } } diff --git a/crates/benilla-app/src/sky_quality.rs b/crates/benilla-app/src/sky_quality.rs index e56503e7..0e54c03d 100644 --- a/crates/benilla-app/src/sky_quality.rs +++ b/crates/benilla-app/src/sky_quality.rs @@ -13,7 +13,11 @@ pub(crate) struct SkyQualityPlugin; impl Plugin for SkyQualityPlugin { fn build(&self, app: &mut App) { - app.init_resource::().add_systems(Update, bridge); + app.init_resource::().add_systems( + Update, + // MONKEY (integration): the tier lands before the sky and cloud readers. + bridge.before(benilla_world::lighting::LightingConsumeSet), + ); } } diff --git a/crates/benilla-app/src/ssao.rs b/crates/benilla-app/src/ssao.rs index 489ea9a7..c997cc96 100644 --- a/crates/benilla-app/src/ssao.rs +++ b/crates/benilla-app/src/ssao.rs @@ -112,10 +112,11 @@ impl Plugin for AmbientOcclusionPlugin { if !app.is_plugin_added::() { return; } + // MONKEY (integration): the crate's shader convention — embedded by `shaders::plugin`. let shader = app - .world_mut() - .resource_mut::>() - .add(Shader::from_wgsl(include_str!("shaders/ssao.wgsl"), "ssao.wgsl")); + .world() + .resource::() + .load("embedded://benilla_app/shaders/ssao.wgsl"); let Some(render_app) = app.get_sub_app_mut(RenderApp) else { return; }; diff --git a/crates/benilla-app/src/ui_script/verb_event_gate.rs b/crates/benilla-app/src/ui_script/verb_event_gate.rs index 8fa3f854..790fc0e1 100644 --- a/crates/benilla-app/src/ui_script/verb_event_gate.rs +++ b/crates/benilla-app/src/ui_script/verb_event_gate.rs @@ -166,6 +166,20 @@ const GAP: &[(&str, &str, &str)] = &[ nothing moves and `ui_char.rs`'s feed has nothing to announce — the stock SkillFrame \ would only repaint under its own Close", ), + // MONKEY (integration): the two temp-point verbs, the same case as `CancelSkillUps` (pre-existing + // upstream gap, surfaced once the Windows path fix let this gate run). + ( + "AddSkillUp", + "SKILL_LINES_CHANGED", + "the reference's helper 0x4d32d0 moves a temp point and fires; the temp-point table stays \ + empty in this model (`skills.rs`), and `ui_char.rs`'s feed fires on any real block change", + ), + ( + "RemoveSkillUp", + "SKILL_LINES_CHANGED", + "the reference's helper 0x4d3480, the same as AddSkillUp: no temp points to move here, and \ + `ui_char.rs`'s feed fires on any real block change", + ), ( "ClickTargetTradeButton", "TRADE_REPLACE_ENCHANT", @@ -279,7 +293,8 @@ fn quoted(text: &str, name: &str) -> bool { } fn short(p: &std::path::Path) -> String { - let s = p.to_string_lossy(); + // MONKEY (integration): Windows paths use a backslash; the rows are written with `/`. + let s = p.to_string_lossy().replace('\\', "/"); s.split_once("/crates/") .map_or(s.to_string(), |(_, t)| t.to_string()) } @@ -370,7 +385,7 @@ fn the_declared_rows_are_still_true() { } let named: Vec<&(PathBuf, String)> = src .iter() - .filter(|(p, _)| p.to_string_lossy().ends_with(file)) + .filter(|(p, _)| short(p).ends_with(file)) // MONKEY (integration): separator-agnostic .collect(); if named.is_empty() { wrong.push(format!( diff --git a/crates/benilla-app/src/ui_session.rs b/crates/benilla-app/src/ui_session.rs index 3ef4700b..a496e5d5 100644 --- a/crates/benilla-app/src/ui_session.rs +++ b/crates/benilla-app/src/ui_session.rs @@ -514,7 +514,8 @@ mod tests { // The chain's own source is the authority on what is wired — not a hand-copied list here, // which would be the very drift this test exists to catch. - let this_file = include_str!("ui_session.rs"); + // MONKEY (integration): a CRLF checkout must still find the function's closing brace. + let this_file = include_str!("ui_session.rs").replace("\r\n", "\n"); let chain = this_file .split_once("pub(crate) fn feed_interact_npc") .expect("feed_interact_npc") diff --git a/crates/benilla-world/src/sky_fx.rs b/crates/benilla-world/src/sky_fx.rs index e8462579..c76ec84e 100644 --- a/crates/benilla-world/src/sky_fx.rs +++ b/crates/benilla-world/src/sky_fx.rs @@ -91,7 +91,8 @@ impl Plugin for SkyFxPlugin { }); let quality = SkyQuality(SkyQuality::env_override().unwrap_or(0)); app.insert_resource(quality) - .add_systems(Update, tick_sky_clock); + // MONKEY (integration): the clock ticks before its readers (sky colours, cloud FX). + .add_systems(Update, tick_sky_clock.before(crate::lighting::LightingConsumeSet)); } } diff --git a/crates/benilla-world/src/skybox.rs b/crates/benilla-world/src/skybox.rs index bd750db4..f14b946b 100644 --- a/crates/benilla-world/src/skybox.rs +++ b/crates/benilla-world/src/skybox.rs @@ -196,6 +196,8 @@ impl Plugin for SkyboxPlugin { // After the lighting resolve, so the weight is this frame's crossfade, as the // fog's is. .after(crate::lighting::LightingResolveSet) + // MONKEY (integration): a `WowLighting` reader joins the consume set. + .in_set(crate::lighting::LightingConsumeSet) .in_set(SkyboxResolve), ) // Camera-anchored placement runs after propagation, off this frame's camera pose. diff --git a/crates/benilla-world/src/weather/wetness.rs b/crates/benilla-world/src/weather/wetness.rs index ebcf73df..1dcca38b 100644 --- a/crates/benilla-world/src/weather/wetness.rs +++ b/crates/benilla-world/src/weather/wetness.rs @@ -124,7 +124,15 @@ fn wetness_tick( pub(super) fn register(app: &mut App) { app.init_resource::() .init_resource::() - .add_systems(Update, wetness_tick.after(WeatherTick)); + // MONKEY (integration): after the wind rows and before the resolve, so the three + // `MonkeyFrame` writers run in one declared order. + .add_systems( + Update, + wetness_tick + .after(WeatherTick) + .after(crate::wind::WindTick) + .before(crate::lighting::LightingResolveSet), + ); } #[cfg(test)] diff --git a/crates/benilla-world/src/wind/mod.rs b/crates/benilla-world/src/wind/mod.rs index f1e811cf..47676254 100644 --- a/crates/benilla-world/src/wind/mod.rs +++ b/crates/benilla-world/src/wind/mod.rs @@ -207,8 +207,10 @@ fn update_wind( fn update_benders( quality: Res, viewer: Res, - viewer_unit: Query<&Transform, (With, With)>, - units: Query<(&Transform, &WorldUnit), Without>, + // MONKEY (integration): `GlobalTransform` (last propagation), not `Transform`, so the scan is + // not an undeclared order against every unit mover in `Update`. + viewer_unit: Query<&GlobalTransform, (With, With)>, + units: Query<(&GlobalTransform, &WorldUnit), Without>, mut frame: ResMut, ) { frame.bender_count = 0; @@ -220,7 +222,7 @@ fn update_benders( // no-avatar fallback that lets the parting instrument exercise the same receiver. let Some(player) = viewer .at - .or_else(|| viewer_unit.single().ok().map(|t| t.translation)) + .or_else(|| viewer_unit.single().ok().map(|t| t.translation())) else { return; }; @@ -231,7 +233,7 @@ fn update_benders( // unit just to retain seven entries. let mut nearby = [(f32::INFINITY, Vec3::ZERO, 1.5); 7]; for (transform, unit) in &units { - let at = transform.translation; + let at = transform.translation(); let d2 = (at.xz() - player.xz()).length_squared(); if d2 > 40.0 * 40.0 || d2 >= nearby[6].0 { continue; @@ -253,6 +255,11 @@ fn update_benders( } } +/// MONKEY (integration): the wind writers' set, so other `MonkeyFrame` writers (wetness) and the +/// viewer publish can order against it. +#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct WindTick; + /// Installs the shared field after this frame's weather ramp has resolved. pub struct WindPlugin; @@ -260,7 +267,18 @@ impl Plugin for WindPlugin { fn build(&self, app: &mut App) { app.init_resource::() .init_resource::() - .add_systems(Update, (update_wind, update_benders).after(WeatherTick)); + // MONKEY (integration): one ordered writer run of `MonkeyFrame`'s wind rows, before + // the lighting resolve (the fog model writes the same resource there). + .add_systems( + Update, + (update_wind, update_benders) + .chain() + .in_set(WindTick) + .after(WeatherTick) + // The camera pose copy also writes `GlobalTransform` (on the camera only). + .after(crate::view::publish_camera_pose) + .before(crate::lighting::LightingResolveSet), + ); } } From a9409c4e380e3088070f14b9a1b389ac0843a4ef Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:10:12 +0000 Subject: [PATCH 79/87] MONKEY (rainshelter): camera-centred rain-occlusion height map 128x128 cells of 0.75 yd around the camera, recentred in 16-cell steps. Filled by vertical rays against the collision world (the precip ground oracle's SpatialQuery + body filter): top surface, then down each hit until an air gap >= 1.2 yd marks the column covered. Cover is eroded two cells (drip margin) and 3x3 blurred (soft edge), packed with the top height into one u32 per cell. 192 casts/frame while filling (nearest first), 32/frame refresh once full; idle and header off when dry or rainSurfaces=0. The grid rides the shared light buffer as a new region right after the MonkeyFrame block; the prop-probe region (and everything chained from it) moves up by 65568 B. Uploads only the header and changed rows. Verified: cargo test -p benilla-world --lib (652 pass, 9 new shelter tests: column probe, transform, recentre, budget, porch shelter/drip, packing, region alignment); cargo check -p benilla. Linux only; no platform seams. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R8t89gSUuo1uvbXKdh1mJt --- .../src/lighting/global_light.rs | 7 +- crates/benilla-world/src/lighting/mod.rs | 1 + .../benilla-world/src/lighting/prop_probes.rs | 5 +- crates/benilla-world/src/weather/mod.rs | 2 + crates/benilla-world/src/weather/shelter.rs | 740 ++++++++++++++++++ crates/benilla-world/src/weather/wetness.rs | 2 +- 6 files changed, 752 insertions(+), 5 deletions(-) create mode 100644 crates/benilla-world/src/weather/shelter.rs diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs index fd86a368..75351625 100644 --- a/crates/benilla-world/src/lighting/global_light.rs +++ b/crates/benilla-world/src/lighting/global_light.rs @@ -704,12 +704,15 @@ pub fn new_shared_light_buffer(device: &RenderDevice) -> SharedLightBuffer { /// whole layout at each draw. pub fn light_blob_bytes() -> u64 { per_frame_blob_bytes() + // MONKEY (rainshelter): the rain-occlusion grid region (`weather::shelter`). + + crate::weather::shelter::REGION_BYTES + (7 * MAX_PROP_PROBES * 16) as u64 + crate::rig_palette::palette_regions_bytes() } -/// The per-frame prefix's size, which is also the probe region's offset. -pub(super) fn per_frame_blob_bytes() -> u64 { +/// The per-frame prefix's size, which is also the rain-shelter region's offset (MONKEY +/// (rainshelter); the probe region follows that). +pub(crate) fn per_frame_blob_bytes() -> u64 { std::mem::size_of::() as u64 } diff --git a/crates/benilla-world/src/lighting/mod.rs b/crates/benilla-world/src/lighting/mod.rs index 74d72add..bcfa1597 100644 --- a/crates/benilla-world/src/lighting/mod.rs +++ b/crates/benilla-world/src/lighting/mod.rs @@ -66,6 +66,7 @@ pub use global_light::{ pub use prop_probes::{PropProbeSlot, PropProbes, MAX_PROP_PROBES}; // The std430 layout stays in the crate: off-world producers state values through `LightBlob`, // never a row index. +pub(crate) use global_light::per_frame_blob_bytes; // MONKEY (rainshelter) pub(crate) use prop_probes::prop_probe_region_offset; pub use resolve::WmoCrossfade; use resolve::{apply_sky_backdrop, setup_lighting, update_time_lighting}; diff --git a/crates/benilla-world/src/lighting/prop_probes.rs b/crates/benilla-world/src/lighting/prop_probes.rs index c6ddd114..01043ece 100644 --- a/crates/benilla-world/src/lighting/prop_probes.rs +++ b/crates/benilla-world/src/lighting/prop_probes.rs @@ -195,9 +195,10 @@ pub(super) fn publish_prop_probes( } } -/// Byte offset of the probe region in the shared light buffer, right after the per-frame blob. +/// Byte offset of the probe region in the shared light buffer, after the per-frame blob and +/// MONKEY (rainshelter) the rain-shelter grid region. pub fn prop_probe_region_offset() -> u64 { - super::global_light::per_frame_blob_bytes() + super::global_light::per_frame_blob_bytes() + crate::weather::shelter::REGION_BYTES } /// Render world, in `PrepareResources`: writes the probe region when the table changed; the diff --git a/crates/benilla-world/src/weather/mod.rs b/crates/benilla-world/src/weather/mod.rs index 09548546..10bc0526 100644 --- a/crates/benilla-world/src/weather/mod.rs +++ b/crates/benilla-world/src/weather/mod.rs @@ -16,6 +16,7 @@ use bevy::prelude::*; use crate::dev_state::DebugState; mod precip; +pub mod shelter; // MONKEY (rainshelter): the rain-occlusion height map the wet lane reads mod wetness; // MONKEY (wet): surface wetness + the rain-ripple clock -> MonkeyFrame wet0 pub use wetness::{RainSurfaces, Wetness}; @@ -312,6 +313,7 @@ impl Plugin for WeatherPlugin { .add_systems(Update, weather_tick.in_set(WeatherTick)); precip::register(app); wetness::register(app); // MONKEY (wet) + shelter::register(app); // MONKEY (rainshelter) } } diff --git a/crates/benilla-world/src/weather/shelter.rs b/crates/benilla-world/src/weather/shelter.rs new file mode 100644 index 00000000..adef8eb7 --- /dev/null +++ b/crates/benilla-world/src/weather/shelter.rs @@ -0,0 +1,740 @@ +//! MONKEY (rainshelter): the rain-occlusion height map. A camera-centred top-down grid +//! ([`GRID`]² cells of [`CELL`] yd, ~96 yd across, recentred in [`STEP`]-cell steps) that says, +//! per cell, how high the topmost surface is and whether open air lies under it, so the wet lane +//! (`wet_hook.wgsl`) keeps ground and walls under porches, bridges and roofs dry. +//! +//! Source: CPU vertical rays against the collision world, the same `SpatialQuery` and +//! [`WorldCollision::body_filter`](crate::collision::WorldCollision::body_filter) the precip +//! ground oracle lands drops on roofs with (`precip/pool.rs`, `HeightCache`). It is the cheapest +//! robust source: the colliders already exist (terrain, WMO groups, collidable doodads), cost +//! nothing to build, and see exactly the roofs the rain itself sees. Each cell casts down from +//! [`LIFT`] yd above the camera: the first hit is the top surface; then it keeps casting from just +//! under each hit (at most [`MAX_HOPS`]) until it finds an air gap of at least [`GAP`] yd (a roof, +//! a deck, a floor over a room) or runs out of surfaces (open ground). A roof's own shell (its +//! outer and ceiling faces a hand apart) is thinner than the gap, so it does not count as shelter. +//! Doodads without a canopy collider (most trees) do not shelter; that is a limit of the source. +//! +//! Per cell the GPU gets one `u32` ([`pack_cell`]): the low half the top height relative to the +//! header's `base_y` in 1/64 yd (±512 yd), the high half the soft cover 0..1 as unorm16. The cover is the +//! raw 0/1 gap flag eroded by two cells (the drip margin: ground just inside an eave stays wet) +//! then box-blurred over 3×3 (the soft edge), so it ramps 1/3, 2/3, 1 inward over ~2 yd. +//! A fragment is sheltered by `cover × smoothstep(0.5, 1.5, top − y)`: the roof itself (at `top`) +//! stays wet, everything a yard or more under it is dry. +//! +//! The grid rides the shared light buffer as its own region, right after the MonkeyFrame block +//! ([`region_offset`]), so no receiver needs a new binding. The region is two header rows +//! (`[origin_x, origin_z, 1/CELL, active]`, `[base_y, GRID, 0, 0]`) and `GRID²` words. +//! +//! Inert unless it is wet: with `rainSurfaces 0` or a zero wetness (`MonkeyFrame::wetness`) the +//! system casts nothing, the header's `active` is 0 and every reader skips the map. It stays live +//! while the ground dries after the rain, so a porch does not turn wet when the rain stops. +//! The precip splashes do not read the map: they already land on the topmost surface through the +//! reference's own ground oracle (`0x67c760`), which is the right answer for a drop. + +use std::sync::Arc; + +use avian3d::prelude::{SpatialQuery, SpatialQueryFilter}; +use bevy::prelude::*; +use bevy::render::extract_resource::{ExtractResource, ExtractResourcePlugin}; +use bevy::render::renderer::RenderQueue; +use bevy::render::{Render, RenderApp, RenderSystems}; + +use crate::lighting::{MonkeyFrame, SharedLightBuffer}; +use crate::view::WorldCamera; + +/// Cells per side. +pub const GRID: usize = 128; +/// Cell size in yd (the grid spans `GRID × CELL` = 96 yd). +pub const CELL: f32 = 0.75; +/// The recentre step in cells (12 yd): the camera stays at least 48 cells from every edge. +pub const STEP: i32 = 16; +/// Ray start above the camera, yd. +pub const LIFT: f32 = 50.0; +/// Camera height drift (yd) that restarts the grid, since the ray start decides which roofs count. +const RECAST_DRIFT: f32 = 20.0; +/// How far a column is searched below its ray start, yd. +const REACH: f32 = 250.0; +/// The smallest air gap under a surface that counts as shelter, yd. +pub const GAP: f32 = 1.2; +/// Surfaces followed down a column before it is called open. +pub const MAX_HOPS: usize = 4; +/// Cells cast per frame while cells are unsampled (nearest the camera first). +pub const FILL_BUDGET: usize = 192; +/// Cells re-cast per frame once full, so streamed-in colliders show up within a few seconds. +pub const REFRESH_BUDGET: usize = 32; +/// The drip margin: covered cells this many cells from open sky count as open (1.5 yd), so a +/// column sampled just under an eave never leaks shelter outside it. +const ERODE: i32 = 2; +/// Header rows before the cells. +pub const HEADER_ROWS: usize = 2; +/// The region's size in the shared light buffer: the header and one word a cell. +pub const REGION_BYTES: u64 = (HEADER_ROWS * 16 + GRID * GRID * 4) as u64; +/// A missing top: far below anything, so `top − y` never shelters. +const NO_TOP: f32 = -60_000.0; + +/// Byte offset of the region: right after the per-frame blob (and before the prop probes). +pub fn region_offset() -> u64 { + crate::lighting::per_frame_blob_bytes() +} + +/// One column's answer. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Column { + /// The topmost surface, world Y; `None` when the column hits nothing. + pub top: Option, + /// Open air of at least [`GAP`] under some surface of the column. + pub covered: bool, +} + +/// Walks one column down from `from_y` with `cast(origin_y, max_dist, first) -> hit distance`. +pub fn probe_column(from_y: f32, mut cast: impl FnMut(f32, f32, bool) -> Option) -> Column { + let Some(d) = cast(from_y, REACH, true) else { + return Column { + top: None, + covered: false, + }; + }; + let top = from_y - d; + let mut y = top; + for _ in 0..MAX_HOPS { + let start = y - 0.02; + let reach = start - (from_y - REACH); + if reach <= 0.0 { + break; + } + let Some(d) = cast(start, reach, false) else { + break; + }; + let next = start - d; + if y - next >= GAP { + return Column { + top: Some(top), + covered: true, + }; + } + y = next; + } + Column { + top: Some(top), + covered: false, + } +} + +/// Height steps a yard in a packed cell (1/64 yd over ±512 yd around `base_y`). +pub const HEIGHT_SCALE: f32 = 64.0; + +/// Packs a cell: the low half the top relative to `base_y` as `rel × 64 + 32768` (clamped), the +/// high half the cover as unorm16. Plain integers, so the shaders need no float16 capability. +pub fn pack_cell(top_rel: f32, cover: f32) -> u32 { + let h = (top_rel * HEIGHT_SCALE + 32_768.0) + .round() + .clamp(0.0, 65_535.0) as u32; + let c = (cover.clamp(0.0, 1.0) * 65_535.0).round() as u32; + h | (c << 16) +} + +/// The inverse of [`pack_cell`]: `(top_rel, cover)`, as `wet_hook::shelter_cell` decodes it. +pub fn unpack_cell(w: u32) -> (f32, f32) { + ( + ((w & 0xffff) as f32 - 32_768.0) / HEIGHT_SCALE, + (w >> 16) as f32 / 65_535.0, + ) +} + +/// The nearest-first cast order: cell indices by distance from the grid centre. +fn fill_order() -> Vec { + let c = GRID as f32 / 2.0 - 0.5; + let mut order: Vec = (0..GRID * GRID).map(|i| i as u16).collect(); + order.sort_by(|&a, &b| { + let d = |i: u16| { + let (x, z) = (f32::from(i) % GRID as f32, (usize::from(i) / GRID) as f32); + (x - c).powi(2) + (z - c).powi(2) + }; + d(a).total_cmp(&d(b)) + }); + order +} + +/// The grid: raw columns, the derived cover and the packed words the GPU reads. +#[derive(Resource)] +pub struct ShelterGrid { + /// World cell coordinates of cell (0, 0) (`floor(world / CELL)`). + pub origin: IVec2, + /// World Y the rays start from. + pub cast_y: f32, + top: Vec, + raw: Vec, + eroded: Vec, + sampled: Vec, + pending: usize, + refresh: usize, + order: Vec, + /// The packed words, shared with the render world. + pub cells: Arc>, + /// Grid rows changed since the last publish, `[lo, hi)`. + dirty: Option<(usize, usize)>, + /// Whether the map is live (it was wet this frame). + pub active: bool, + started: bool, +} + +impl Default for ShelterGrid { + fn default() -> Self { + let n = GRID * GRID; + Self { + origin: IVec2::ZERO, + cast_y: 0.0, + top: vec![NO_TOP; n], + raw: vec![0.0; n], + eroded: vec![0.0; n], + sampled: vec![false; n], + pending: n, + refresh: 0, + order: fill_order(), + cells: Arc::new(vec![pack_cell(NO_TOP, 0.0); n]), + dirty: None, + active: false, + started: false, + } + } +} + +impl ShelterGrid { + /// The origin a camera at `cam_xz` wants: its step, less half the grid. + pub fn origin_for(cam_xz: Vec2) -> IVec2 { + let cell = (cam_xz / CELL).floor().as_ivec2(); + (cell.div_euclid(IVec2::splat(STEP)) - IVec2::splat(GRID as i32 / STEP / 2)) * STEP + } + + /// The Y relative heights are stored against. + pub fn base_y(&self) -> f32 { + self.cast_y - LIFT + } + + /// The cell holding a world XZ, if inside. + pub fn cell_of(&self, xz: Vec2) -> Option<(usize, usize)> { + let c = (xz / CELL).floor().as_ivec2() - self.origin; + let n = GRID as i32; + (c.x >= 0 && c.y >= 0 && c.x < n && c.y < n).then_some((c.x as usize, c.y as usize)) + } + + /// A cell's centre in world XZ. + pub fn cell_centre(&self, x: usize, z: usize) -> Vec2 { + (self.origin.as_vec2() + Vec2::new(x as f32 + 0.5, z as f32 + 0.5)) * CELL + } + + /// Cells still to cast before the grid is whole. + pub fn pending(&self) -> usize { + self.pending + } + + /// Forgets every column. + pub fn clear(&mut self) { + self.top.fill(NO_TOP); + self.raw.fill(0.0); + self.sampled.fill(false); + self.pending = GRID * GRID; + self.refresh = 0; + self.rederive_all(); + } + + /// Follows the camera: shifts the grid by whole steps (keeping every column still inside) + /// and restarts it when the camera has climbed or dropped past [`RECAST_DRIFT`]. Returns + /// whether anything moved. + pub fn follow(&mut self, cam: Vec3) -> bool { + let want_y = cam.y + LIFT; + if !self.started || (want_y - self.cast_y).abs() > RECAST_DRIFT { + self.started = true; + self.cast_y = want_y; + self.origin = Self::origin_for(cam.xz()); + self.clear(); + return true; + } + let want = Self::origin_for(cam.xz()); + if want == self.origin { + return false; + } + let d = want - self.origin; + self.origin = want; + let n = GRID as i32; + let mut top = vec![NO_TOP; GRID * GRID]; + let mut raw = vec![0.0; GRID * GRID]; + let mut sampled = vec![false; GRID * GRID]; + for z in 0..n { + for x in 0..n { + let (ox, oz) = (x + d.x, z + d.y); + if ox < 0 || oz < 0 || ox >= n || oz >= n { + continue; + } + let (i, o) = ((z * n + x) as usize, (oz * n + ox) as usize); + top[i] = self.top[o]; + raw[i] = self.raw[o]; + sampled[i] = self.sampled[o]; + } + } + self.top = top; + self.raw = raw; + self.pending = sampled.iter().filter(|s| !**s).count(); + self.sampled = sampled; + self.rederive_all(); + true + } + + /// Casts up to a frame's budget of columns with `probe(xz)`, nearest unsampled first, then a + /// slow refresh sweep. Returns how many columns were cast. + pub fn step(&mut self, mut probe: impl FnMut(Vec2) -> Column) -> usize { + let mut cast = 0; + if self.pending > 0 { + for k in 0..self.order.len() { + if cast == FILL_BUDGET { + break; + } + let i = usize::from(self.order[k]); + if self.sampled[i] { + continue; + } + self.cast_cell(i, &mut probe); + cast += 1; + } + } else { + for _ in 0..REFRESH_BUDGET { + let i = usize::from(self.order[self.refresh]); + self.refresh = (self.refresh + 1) % self.order.len(); + self.cast_cell(i, &mut probe); + cast += 1; + } + } + cast + } + + fn cast_cell(&mut self, i: usize, probe: &mut impl FnMut(Vec2) -> Column) { + let (x, z) = (i % GRID, i / GRID); + let col = probe(self.cell_centre(x, z)); + if !self.sampled[i] { + self.sampled[i] = true; + self.pending -= 1; + } + let top = col.top.unwrap_or(NO_TOP); + let raw = if col.covered { 1.0 } else { 0.0 }; + if top != self.top[i] || raw != self.raw[i] { + self.top[i] = top; + self.raw[i] = raw; + self.rederive(x, z); + } + } + + fn rederive_all(&mut self) { + self.rederive_rect(0, 0, GRID - 1, GRID - 1); + } + + /// A raw change reaches the cover `ERODE + 1` cells out (erode, then blur). + fn rederive(&mut self, x: usize, z: usize) { + let r = ERODE as usize + 1; + self.rederive_rect( + x.saturating_sub(r), + z.saturating_sub(r), + (x + r).min(GRID - 1), + (z + r).min(GRID - 1), + ); + } + + /// Recomputes the cover and the packed words over an inclusive rect. + fn rederive_rect(&mut self, x0: usize, z0: usize, x1: usize, z1: usize) { + let n = GRID as i32; + let at = |v: &Vec, x: i32, z: i32| { + if x < 0 || z < 0 || x >= n || z >= n { + 0.0 + } else { + v[(z * n + x) as usize] + } + }; + // Erosion over the rect grown by one (the blur reads it). + let (ex0, ez0) = (x0.saturating_sub(1), z0.saturating_sub(1)); + let (ex1, ez1) = ((x1 + 1).min(GRID - 1), (z1 + 1).min(GRID - 1)); + for z in ez0..=ez1 { + for x in ex0..=ex1 { + let mut m = 1.0f32; + for dz in -ERODE..=ERODE { + for dx in -ERODE..=ERODE { + m = m.min(at(&self.raw, x as i32 + dx, z as i32 + dz)); + } + } + self.eroded[z * GRID + x] = m; + } + } + let base = self.base_y(); + let cells = Arc::make_mut(&mut self.cells); + for z in z0..=z1 { + for x in x0..=x1 { + let mut s = 0.0; + for dz in -1..=1 { + for dx in -1..=1 { + s += at(&self.eroded, x as i32 + dx, z as i32 + dz); + } + } + let i = z * GRID + x; + cells[i] = pack_cell(self.top[i] - base, s / 9.0); + } + } + self.dirty = Some(match self.dirty { + Some((lo, hi)) => (lo.min(z0), hi.max(z1 + 1)), + None => (z0, z1 + 1), + }); + } + + /// The two header rows. + pub fn header(&self) -> [[f32; 4]; HEADER_ROWS] { + let o = self.origin.as_vec2() * CELL; + [ + [o.x, o.y, 1.0 / CELL, if self.active { 1.0 } else { 0.0 }], + [self.base_y(), GRID as f32, 0.0, 0.0], + ] + } + + /// The sheltered amount a fragment at `p` gets: the CPU twin of `wet_hook::shelter_amount` + /// (bilinear over the four nearest cell centres), for tests and probes. + pub fn shelter_at(&self, p: Vec3) -> f32 { + if !self.active { + return 0.0; + } + let g = (p.xz() / CELL - self.origin.as_vec2()) - 0.5; + let gf = g.floor(); + if gf.x < 0.0 + || gf.y < 0.0 + || gf.x + 1.0 > (GRID - 1) as f32 + || gf.y + 1.0 > (GRID - 1) as f32 + { + return 0.0; + } + let f = g - gf; + let (x, z) = (gf.x as usize, gf.y as usize); + let y_rel = p.y - self.base_y(); + let tap = |x: usize, z: usize| { + let (top, cover) = unpack_cell(self.cells[z * GRID + x]); + let t = (top - y_rel - 0.5).clamp(0.0, 1.0); + cover * t * t * (3.0 - 2.0 * t) + }; + let a = tap(x, z) + (tap(x + 1, z) - tap(x, z)) * f.x; + let b = tap(x, z + 1) + (tap(x + 1, z + 1) - tap(x, z + 1)) * f.x; + (a + (b - a) * f.y).clamp(0.0, 1.0) + } +} + +/// The render-world copy: the header every frame, the cells when they changed. +#[derive(Resource, Clone, ExtractResource)] +pub struct ShelterExtract { + header: [[f32; 4]; HEADER_ROWS], + cells: Arc>, + generation: u64, + /// Rows this generation changed, `[lo, hi)`. + rows: Option<(usize, usize)>, +} + +impl Default for ShelterExtract { + fn default() -> Self { + Self { + header: [[0.0; 4]; HEADER_ROWS], + cells: Arc::new(Vec::new()), + generation: 0, + rows: None, + } + } +} + +/// Main world: follows the camera and casts this frame's columns while it is wet; idle (no rays, +/// the header marked off once) otherwise. +fn tick_shelter( + frame: Res, + cam: Query<&GlobalTransform, With>, + spatial: SpatialQuery, + mut grid: ResMut, + mut out: ResMut, +) { + let wet = frame.wetness > 0.0; + let cam = cam.single().ok().map(GlobalTransform::translation); + let live = wet && cam.is_some(); + if !live { + if grid.active { + grid.active = false; + // Stale columns would lie on the next shower; forget them. + grid.started = false; + out.header = grid.header(); + } + return; + } + let cam = cam.unwrap_or_default(); + grid.active = true; + grid.follow(cam); + let filter = crate::collision::WorldCollision::body_filter(); + let x0 = grid.cast_y; + grid.step(|xz| cast_column(&spatial, &filter, xz, x0)); + let header = grid.header(); + if out.header != header { + out.header = header; + } + if let Some(rows) = grid.dirty.take() { + out.cells = Arc::clone(&grid.cells); + out.generation = out.generation.wrapping_add(1); + out.rows = Some(rows); + } +} + +fn cast_column( + spatial: &SpatialQuery, + filter: &SpatialQueryFilter, + xz: Vec2, + from_y: f32, +) -> Column { + probe_column(from_y, |y, max, first| { + spatial + .cast_ray(Vec3::new(xz.x, y, xz.y), Dir3::NEG_Y, max, first, filter) + .map(|hit| hit.distance) + }) +} + +/// Render world: writes the header each frame it moved and the changed cell rows. +fn upload_shelter( + queue: Res, + buffer: Option>, + data: Option>, + mut last: Local<(u64, [[f32; 4]; HEADER_ROWS])>, +) { + let (Some(buffer), Some(data)) = (buffer, data) else { + return; + }; + if last.1 != data.header { + last.1 = data.header; + queue.write_buffer( + &buffer.0, + region_offset(), + bytemuck::cast_slice(&data.header), + ); + } + if last.0 == data.generation || data.cells.is_empty() { + return; + } + // Only the changed rows after a consecutive generation; everything after a skipped one. + let (lo, hi) = match data.rows { + Some(r) if last.0.wrapping_add(1) == data.generation => r, + _ => (0, GRID), + }; + last.0 = data.generation; + let words = &data.cells[lo * GRID..hi * GRID]; + queue.write_buffer( + &buffer.0, + region_offset() + (HEADER_ROWS * 16 + lo * GRID * 4) as u64, + bytemuck::cast_slice(words), + ); +} + +pub(super) fn register(app: &mut App) { + app.init_resource::() + .init_resource::() + .add_plugins(ExtractResourcePlugin::::default()) + .add_systems(Update, tick_shelter.after(super::wetness::wetness_tick)); + if let Some(render_app) = app.get_sub_app_mut(RenderApp) { + render_app.add_systems( + Render, + upload_shelter.in_set(RenderSystems::PrepareResources), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A flat ground at y 0 with a 6×6 yd porch roof (outer face 3.2, ceiling 3.0) at x, z 10..16. + fn porch(xz: Vec2) -> impl FnMut(f32, f32, bool) -> Option { + move |y: f32, max: f32, _first: bool| { + let under = (10.0..16.0).contains(&xz.x) && (10.0..16.0).contains(&xz.y); + let surfaces: &[f32] = if under { &[3.2, 3.0, 0.0] } else { &[0.0] }; + surfaces + .iter() + .find(|&&s| s <= y && y - s <= max) + .map(|&s| y - s) + } + } + + fn filled(cam: Vec3) -> ShelterGrid { + let mut g = ShelterGrid { + active: true, + ..Default::default() + }; + g.follow(cam); + let from = g.cast_y; + while g.pending() > 0 { + g.step(|xz| probe_column(from, porch(xz))); + } + g + } + + #[test] + fn a_column_under_a_roof_is_covered_and_open_ground_is_not() { + let roof = probe_column(50.0, porch(Vec2::new(12.0, 12.0))); + assert!( + roof.covered && (roof.top.unwrap() - 3.2).abs() < 1e-4, + "{roof:?}" + ); + let open = probe_column(50.0, porch(Vec2::new(0.0, 0.0))); + assert_eq!( + open, + Column { + top: Some(0.0), + covered: false + } + ); + let none = probe_column(50.0, |_, _, _| None); + assert_eq!( + none, + Column { + top: None, + covered: false + } + ); + } + + #[test] + fn a_thin_shell_is_not_shelter() { + // Two faces 0.3 yd apart and nothing under them: a slab lying on the ground. + let c = probe_column(10.0, |y, _, _| { + [1.3f32, 1.0].iter().find(|&&s| s <= y).map(|&s| y - s) + }); + assert!(!c.covered); + } + + #[test] + fn the_transform_round_trips_and_the_camera_sits_mid_grid() { + for cam in [ + Vec2::new(0.0, 0.0), + Vec2::new(-8_532.4, 671.9), + Vec2::new(123.4, -9_876.5), + ] { + let mut g = ShelterGrid::default(); + g.follow(cam.extend(20.0).xzy()); + let (x, z) = g.cell_of(cam).expect("the camera is inside its grid"); + for c in [x, z] { + assert!((48..80).contains(&c), "camera cell {c} too near an edge"); + } + let centre = g.cell_centre(x, z); + assert!((centre - cam).abs().max_element() <= CELL / 2.0 + 1e-3); + assert_eq!(g.cell_of(centre), Some((x, z))); + } + } + + #[test] + fn the_grid_recentres_only_in_whole_steps_and_keeps_its_columns() { + let mut g = filled(Vec3::new(13.0, 1.0, 13.0)); + let origin = g.origin; + // Within a step: nothing moves. + assert!(!g.follow(Vec3::new(13.0 + 5.0, 1.0, 13.0))); + assert_eq!(g.origin, origin); + // Past it: one step, and only the new strip is pending. + assert!(g.follow(Vec3::new(13.0 + STEP as f32 * CELL, 1.0, 13.0))); + assert_eq!(g.origin - origin, IVec2::new(STEP, 0)); + assert_eq!(g.pending(), STEP as usize * GRID); + // The porch kept its shelter across the shift. + assert!(g.shelter_at(Vec3::new(13.0, 0.0, 13.0)) > 0.99); + // A big climb restarts the grid. + assert!(g.follow(Vec3::new( + 13.0 + STEP as f32 * CELL, + 1.0 + RECAST_DRIFT + 1.0, + 13.0 + ))); + assert_eq!(g.pending(), GRID * GRID); + } + + #[test] + fn a_frame_casts_at_most_its_budget_nearest_first() { + let mut g = ShelterGrid { + active: true, + ..Default::default() + }; + g.follow(Vec3::new(13.0, 1.0, 13.0)); + let mut calls = 0; + let mut far = 0.0f32; + let n = g.step(|xz| { + calls += 1; + far = far.max((xz - Vec2::new(13.0, 13.0)).length()); + Column { + top: Some(0.0), + covered: false, + } + }); + assert_eq!((n, calls), (FILL_BUDGET, FILL_BUDGET)); + // The first frame covers the camera's own neighbourhood (grid centre is ≤ a step away). + assert!(far < 20.0, "first cells reach {far} yd"); + assert_eq!(g.pending(), GRID * GRID - FILL_BUDGET); + while g.pending() > 0 { + g.step(|_| Column { + top: Some(0.0), + covered: false, + }); + } + // Full: only the refresh trickle. + assert_eq!( + g.step(|_| Column { + top: Some(0.0), + covered: false + }), + REFRESH_BUDGET + ); + } + + #[test] + fn under_the_porch_is_dry_the_roof_and_the_drip_line_are_wet() { + let g = filled(Vec3::new(13.0, 1.0, 13.0)); + // Ground under the middle of the porch: sheltered. + assert!(g.shelter_at(Vec3::new(13.0, 0.0, 13.0)) > 0.99); + // The roof's own top: open. + assert_eq!(g.shelter_at(Vec3::new(13.0, 3.2, 13.0)), 0.0); + // Open ground: open. + assert_eq!(g.shelter_at(Vec3::new(5.0, 0.0, 5.0)), 0.0); + // Just inside the eave (the drip margin): mostly wet; a yard in: mostly dry. + let edge = g.shelter_at(Vec3::new(10.2, 0.0, 13.0)); + let inner = g.shelter_at(Vec3::new(11.8, 0.0, 13.0)); + assert!(edge < 0.5, "drip line {edge}"); + assert!(inner > edge && inner > 0.6, "inner {inner}"); + // At and just outside the eave: fully wet. + assert_eq!(g.shelter_at(Vec3::new(9.6, 0.0, 13.0)), 0.0); + assert_eq!(g.shelter_at(Vec3::new(10.1, 0.0, 13.0)), 0.0); + // Walls and floors a story under the roof are dry too; a fragment above it is not. + assert!(g.shelter_at(Vec3::new(13.0, 1.5, 13.0)) > 0.99); + assert_eq!(g.shelter_at(Vec3::new(13.0, 4.0, 13.0)), 0.0); + } + + #[test] + fn an_inactive_grid_shelters_nothing_and_packs_off() { + let mut g = filled(Vec3::new(13.0, 1.0, 13.0)); + g.active = false; + assert_eq!(g.shelter_at(Vec3::new(13.0, 0.0, 13.0)), 0.0); + assert_eq!(g.header()[0][3], 0.0); + } + + #[test] + fn packed_cells_round_trip_to_a_sixty_fourth_of_a_yard() { + for (top, cover) in [ + (0.0f32, 0.0f32), + (1.0, 1.0), + (-2.5, 0.5), + (3.21, 1.0 / 3.0), + (-60.0, 0.0), + ] { + let (t, c) = unpack_cell(pack_cell(top, cover)); + assert!((t - top).abs() <= 0.5 / HEIGHT_SCALE, "{top} -> {t}"); + assert!((c - cover).abs() <= 1e-4, "{cover} -> {c}"); + } + // Out of range clamps: a missing top reads 512 yd under the base. + assert_eq!(unpack_cell(pack_cell(NO_TOP, 2.0)), (-512.0, 1.0)); + } + + #[test] + fn the_region_is_aligned_for_the_probe_rows_after_it() { + assert_eq!(REGION_BYTES % 16, 0); + assert_eq!(region_offset() % 16, 0); + assert_eq!( + crate::lighting::prop_probe_region_offset(), + region_offset() + REGION_BYTES + ); + } +} diff --git a/crates/benilla-world/src/weather/wetness.rs b/crates/benilla-world/src/weather/wetness.rs index ebcf73df..87512e27 100644 --- a/crates/benilla-world/src/weather/wetness.rs +++ b/crates/benilla-world/src/weather/wetness.rs @@ -85,7 +85,7 @@ fn env_overrides() -> &'static EnvOverrides { }) } -fn wetness_tick( +pub(super) fn wetness_tick( time: Res>, state: Res, cvar: Res, From 8b3c07fc11ff175cb6557d3cdf0507153d4f821e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:10:12 +0000 Subject: [PATCH 80/87] MONKEY (rainshelter): receivers read the shelter map wet_hook gains shelter_taps/shelter_cell/shelter_amount (bilinear over four cells; sheltered when a covered top is >= ~1 yd above the fragment, the roof's own surface stays open). terrain, wow_model and static_gx mirror the region after monkey and scale wet exposure by 1 - shelter (wet_puddles takes exposure, so no puddles under cover); enhanced_water suppresses rain rings under bridges/roofs via its row view. The lookup runs only while wetness > 0 and the header is active, so a dry frame is unchanged. Verified: the wet_hook + receiver/water lookup snippets composed and validated with naga_oil 0.20 / naga 27 (scratch harness; unpack2x16float avoided - it needs a float16 capability). Full receiver shaders were not compiled here (no GPU, no game data). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R8t89gSUuo1uvbXKdh1mJt --- .../src/shaders/enhanced_water.wgsl | 30 ++++++++- .../benilla-assets/src/shaders/terrain.wgsl | 20 +++++- .../benilla-assets/src/shaders/wet_hook.wgsl | 62 +++++++++++++++++-- .../benilla-assets/src/shaders/wow_model.wgsl | 20 +++++- .../benilla-world/src/shaders/static_gx.wgsl | 18 +++++- 5 files changed, 138 insertions(+), 12 deletions(-) diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl index 411ffc0f..faf60bd9 100644 --- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl +++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl @@ -25,6 +25,8 @@ #import bevy_pbr::mesh_view_bindings::{view, globals} // MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table. #import benilla::monkey_frame +// MONKEY (rainshelter): the shelter-grid lookup the surface receivers share. +#import benilla::wet_hook // MONKEY (p0 fog hook): the one distance-fog law every receiver calls. #import benilla::fog_hook @@ -66,6 +68,25 @@ fn water_monkey() -> monkey_frame::MonkeyFrame { return m; } +// MONKEY (rainshelter): the rain-occlusion grid right after the MonkeyFrame block +// (`weather/shelter.rs`): two header rows, then the packed cell words, four to a row. +const SHELTER_ROW: u32 = 549u; +fn water_shelter_word(i: u32) -> u32 { + return bitcast(water_light[SHELTER_ROW + 2u + i / 4u][i % 4u]); +} +// 1 open .. 0 under a bridge or a roof. +fn water_rain_open(world_pos: vec3) -> f32 { + let hdr = water_light[SHELTER_ROW]; + let cfg = water_light[SHELTER_ROW + 1u]; + let st = wet_hook::shelter_taps(world_pos, hdr, cfg); + if (st.live <= 0.0) { + return 1.0; + } + return 1.0 - wet_hook::shelter_amount(st, water_shelter_word(st.idx.x), + water_shelter_word(st.idx.y), water_shelter_word(st.idx.z), water_shelter_word(st.idx.w), + world_pos.y, cfg); +} + // What the fragment stage hands over: the three varyings this module reads. struct WaterFragment { clip_position: vec4, @@ -256,13 +277,16 @@ fn ripple_hash(p: vec2) -> vec2 { q += dot(q, q.yzx + 33.33); return fract((q.xx + q.yz) * q.zy); } -fn rain_ripple_normal(world_xz: vec2, footprint: f32, distance: f32, room: bool) -> vec2 { +fn rain_ripple_normal(world_pos: vec3, footprint: f32, distance: f32, room: bool) -> vec2 { let wet_row = water_monkey().wet_a; let rain = clamp(wet_row.x, 0.0, 1.0); if rain <= 0.0 || water.lane.x > 1.5 || room { return vec2(0.0); } - let reach = (1.0 - smoothstep(35.0, 60.0, distance)) * (1.0 - smoothstep(0.05, 0.14, footprint)); + let world_xz = world_pos.xz; + // MONKEY (rainshelter): no rings under a bridge or a roof. + let reach = (1.0 - smoothstep(35.0, 60.0, distance)) * (1.0 - smoothstep(0.05, 0.14, footprint)) + * water_rain_open(world_pos); if reach <= 0.0 { return vec2(0.0); } @@ -595,7 +619,7 @@ fn enhanced_water(in: WaterFragment, shallow: vec4, deep: vec4) -> vec open_sea_fold = water_gerstner(p, t, shore).w; } // One surface gradient: the procedural bands and the shore break. - let surf_grad = wave.yz + shore_grad + rain_ripple_normal(in.world_position.xz, footprint, + let surf_grad = wave.yz + shore_grad + rain_ripple_normal(in.world_position.xyz, footprint, length(eye_pos), in.room_fog != 0u && water.lane.w > 0.5); var n = normalize(vec3(-surf_grad.x, 1.0, -surf_grad.y)); if dot(n, to_view) < 0.0 { n = -n; } diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl index 9bc38773..4ea363e3 100644 --- a/crates/benilla-assets/src/shaders/terrain.wgsl +++ b/crates/benilla-assets/src/shaders/terrain.wgsl @@ -64,6 +64,11 @@ struct WowLight { points: array, 512>, // MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl). monkey: monkey_frame::MonkeyFrame, + // MONKEY (rainshelter): the rain-occlusion grid (`weather/shelter.rs`): `[origin_x, origin_z, + // 1/cell, active]`, `[base_y, cells per side, 0, 0]`, then one packed word a cell (wet_hook.wgsl). + shelter_hdr: vec4, + shelter_cfg: vec4, + shelter: array, }; @group(#{MATERIAL_BIND_GROUP}) @binding(90) var wow_light: WowLight; @@ -757,9 +762,20 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 { // specular = per-vertex sheen · gloss_mask · shadow → gated to ZERO in shadow (no sheen in shade) // (`tex·primary` is the MODULATE-1× diffuse; the sheen is added after, separate-specular.) Then // LDR-clamp; gamma/byte throughout; raw gamma out (GAMMA LANE, 0161). + // MONKEY (rainshelter): nothing gets wet under a roof, porch or bridge (the shelter grid). + var wet_open = 1.0; + if (wow_light.monkey.wet_a.y > 0.0) { + let wet_st = wet_hook::shelter_taps(in.world_position.xyz, wow_light.shelter_hdr, + wow_light.shelter_cfg); + if (wet_st.live > 0.0) { + wet_open = 1.0 - wet_hook::shelter_amount(wet_st, wow_light.shelter[wet_st.idx.x], + wow_light.shelter[wet_st.idx.y], wow_light.shelter[wet_st.idx.z], + wow_light.shelter[wet_st.idx.w], in.world_position.y, wow_light.shelter_cfg); + } + } // MONKEY (wet): rain-darkened ground + puddles (wet_hook.wgsl); a dry frame returns `color`. - let wet = wet_hook::wet_puddles(wet_hook::wet_surface(color, n_lit, in.world_position.xyz, 1.0, - wow_light.monkey), n_lit, in.world_position.xyz, wow_light.monkey); + let wet = wet_hook::wet_puddles(wet_hook::wet_surface(color, n_lit, in.world_position.xyz, + wet_open, wow_light.monkey), n_lit, in.world_position.xyz, wet_open, wow_light.monkey); color = wet.albedo; let diffuse_term = color * primary * (0.3 * shadow_lit_eff + 0.7) * character_shadow_term; let spec_term = in.specular * specmask * spec_gate; diff --git a/crates/benilla-assets/src/shaders/wet_hook.wgsl b/crates/benilla-assets/src/shaders/wet_hook.wgsl index 4bf3b73b..c09903e4 100644 --- a/crates/benilla-assets/src/shaders/wet_hook.wgsl +++ b/crates/benilla-assets/src/shaders/wet_hook.wgsl @@ -13,6 +13,57 @@ #import benilla::monkey_frame::MonkeyFrame +// MONKEY (rainshelter): the four cells around a fragment in the camera-centred shelter grid. +// `hdr` = `[origin_x, origin_z, 1 / cell, active]`, `cfg` = `[base_y, cells per side, 0, 0]`; +// `live` is 0 when the map is off or the fragment lies outside it (open sky). +struct ShelterTaps { + idx: vec4, + frac: vec2, + live: f32, +} + +fn shelter_taps(world_pos: vec3, hdr: vec4, cfg: vec4) -> ShelterTaps { + var t: ShelterTaps; + t.idx = vec4(0u); + t.frac = vec2(0.0); + t.live = 0.0; + if (hdr.w < 0.5) { + return t; + } + let g = (world_pos.xz - hdr.xy) * hdr.z - 0.5; + let gf = floor(g); + if (gf.x < 0.0 || gf.y < 0.0 || gf.x + 1.0 > cfg.y - 1.0 || gf.y + 1.0 > cfg.y - 1.0) { + return t; + } + let side = u32(cfg.y); + let first = u32(gf.y) * side + u32(gf.x); + t.idx = vec4(first, first + 1u, first + side, first + side + 1u); + t.frac = g - gf; + t.live = 1.0; + return t; +} + +// One cell: low half `(top − base_y) × 64 + 32768`, high half the soft cover as unorm16 +// (`shelter::pack_cell`). Sheltered when a covered top lies a yard or more above the fragment; the +// roof's own surface (at `top`) stays open. +fn shelter_cell(word: u32, y_rel: f32) -> f32 { + let top = (f32(word & 0xffffu) - 32768.0) / 64.0; + let cover = f32(word >> 16u) / 65535.0; + return cover * smoothstep(0.5, 1.5, top - y_rel); +} + +// How sheltered the fragment is, 0 open .. 1 under cover, bilinear over the four cells. +fn shelter_amount(t: ShelterTaps, wa: u32, wb: u32, wc: u32, wd: u32, world_y: f32, + cfg: vec4) -> f32 { + if (t.live <= 0.0) { + return 0.0; + } + let y_rel = world_y - cfg.x; + let near = mix(shelter_cell(wa, y_rel), shelter_cell(wb, y_rel), t.frac.x); + let far = mix(shelter_cell(wc, y_rel), shelter_cell(wd, y_rel), t.frac.x); + return clamp(mix(near, far, t.frac.y), 0.0, 1.0); +} + struct WetSurface { albedo: vec3, // Gloss weight for `wet_sheen`: 0 dry, ~1 a soaked top face, up to 2 in a terrain puddle (the @@ -61,11 +112,13 @@ fn wet_noise(p: vec2) -> f32 { } // Terrain bonus: puddle patches on flat ground once it is well soaked — darker, and a stronger -// gloss weight so they read as standing water catching the sky. -fn wet_puddles(s: WetSurface, n: vec3, world_pos: vec3, mf: MonkeyFrame) -> WetSurface { +// gloss weight so they read as standing water catching the sky. MONKEY (rainshelter): `exposure` +// as for `wet_surface`, so no puddle forms under cover. +fn wet_puddles(s: WetSurface, n: vec3, world_pos: vec3, exposure: f32, + mf: MonkeyFrame) -> WetSurface { let wet_row = mf.wet_a; var o = s; - if (wet_row.y <= 0.0) { + if (wet_row.y <= 0.0 || exposure <= 0.0) { return o; } let xz = world_pos.xz; @@ -73,7 +126,8 @@ fn wet_puddles(s: WetSurface, n: vec3, world_pos: vec3, mf: MonkeyFram let level = smoothstep(0.94, 0.99, n.y); // Puddles grow with the wetness: the threshold falls as the ground soaks. let edge = mix(0.8, 0.6, smoothstep(0.4, 1.0, wet_row.y)); - let puddle = smoothstep(edge, edge + 0.06, field) * level * smoothstep(0.35, 0.8, wet_row.y); + let puddle = smoothstep(edge, edge + 0.06, field) * level * smoothstep(0.35, 0.8, wet_row.y) + * clamp(exposure, 0.0, 1.0); o.albedo = o.albedo * (1.0 - 0.45 * puddle); o.boost = max(o.boost, 2.0 * puddle); return o; diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index d4810031..4a4a70a0 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -126,6 +126,11 @@ struct WowLight { points: array, 512>, // MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl). monkey: monkey_frame::MonkeyFrame, + // MONKEY (rainshelter): the rain-occlusion grid (`weather/shelter.rs`): `[origin_x, origin_z, + // 1/cell, active]`, `[base_y, cells per side, 0, 0]`, then one packed word a cell (wet_hook.wgsl). + shelter_hdr: vec4, + shelter_cfg: vec4, + shelter: array, // The interior-prop SH probes, 7 rows per slot (`MAX_PROP_PROBES` = 8192 slots). Only this // shader declares this tail; the other shaders bind the same buffer by its prefix. prop_probes: array, 57344>, @@ -1740,8 +1745,19 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut { // Mod/Mod2x, additive and dry = untouched. let wet_dry = is_interior || interior_fogged || is_rig || is_emissive || is_mod || is_mod2x || (u32(m.clutter_fade.z) & 4u) != 0u; - let wet = wet_hook::wet_surface(rgb, n_lit, in.world_position.xyz, select(1.0, 0.0, wet_dry), - wow_light.monkey); + // MONKEY (rainshelter): nothing gets wet under a roof, porch or bridge (the shelter grid). + var wet_open = 1.0; + if (wow_light.monkey.wet_a.y > 0.0) { + let wet_st = wet_hook::shelter_taps(in.world_position.xyz, wow_light.shelter_hdr, + wow_light.shelter_cfg); + if (wet_st.live > 0.0) { + wet_open = 1.0 - wet_hook::shelter_amount(wet_st, wow_light.shelter[wet_st.idx.x], + wow_light.shelter[wet_st.idx.y], wow_light.shelter[wet_st.idx.z], + wow_light.shelter[wet_st.idx.w], in.world_position.y, wow_light.shelter_cfg); + } + } + let wet = wet_hook::wet_surface(rgb, n_lit, in.world_position.xyz, + select(1.0, 0.0, wet_dry) * wet_open, wow_light.monkey); rgb = wet.albedo; if (wet.boost > 0.0) { rgb = min(rgb + wet_hook::wet_sheen(wet.boost, n_lit, normalize(view.world_position - in.world_position.xyz), diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl index 9009163e..142c032e 100644 --- a/crates/benilla-world/src/shaders/static_gx.wgsl +++ b/crates/benilla-world/src/shaders/static_gx.wgsl @@ -52,6 +52,11 @@ struct WowLight { points: array, 512>, // MONKEY (p0 MonkeyFrame): the programme block after the point table (monkey_frame.wgsl). monkey: monkey_frame::MonkeyFrame, + // MONKEY (rainshelter): the rain-occlusion grid (`weather/shelter.rs`): `[origin_x, origin_z, + // 1/cell, active]`, `[base_y, cells per side, 0, 0]`, then one packed word a cell (wet_hook.wgsl). + shelter_hdr: vec4, + shelter_cfg: vec4, + shelter: array, // lighting::prop_probes: 8192 slots of 7 rows; the buffer's later regions are not mirrored. prop_probes: array, 57344>, } @@ -1993,10 +1998,21 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 { if ((in.word & WORD_UNLIT) != 0u) { rgb = folded; } + // MONKEY (rainshelter): nothing gets wet under a roof, porch or bridge (the shelter grid). + var wet_open = 1.0; + if (wow_light.monkey.wet_a.y > 0.0) { + let wet_st = wet_hook::shelter_taps(in.world_position.xyz, wow_light.shelter_hdr, + wow_light.shelter_cfg); + if (wet_st.live > 0.0) { + wet_open = 1.0 - wet_hook::shelter_amount(wet_st, wow_light.shelter[wet_st.idx.x], + wow_light.shelter[wet_st.idx.y], wow_light.shelter[wet_st.idx.z], + wow_light.shelter[wet_st.idx.w], in.world_position.y, wow_light.shelter_cfg); + } + } // MONKEY (wet): rain on sky-exposed surfaces (wet_hook.wgsl); interior, unlit and dry = untouched. let wet = wet_hook::wet_surface(rgb, n_lit, in.world_position.xyz, // MONKEY (fix-wet): lit WMO windows (WORD_WINDOW) are not darkened by rain either. - select(1.0, 0.0, (in.word & (WORD_INTERIOR | WORD_UNLIT | WORD_WINDOW)) != 0u), + select(1.0, 0.0, (in.word & (WORD_INTERIOR | WORD_UNLIT | WORD_WINDOW)) != 0u) * wet_open, wow_light.monkey); rgb = wet.albedo; if (wet.boost > 0.0) { From 2f467713f524c39912232a3ffb857cbcde66a61a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 18:10:12 +0000 Subject: [PATCH 81/87] MONKEY (rainshelter): document the rain-occlusion map and buffer region Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R8t89gSUuo1uvbXKdh1mJt --- LIGHTING.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/LIGHTING.md b/LIGHTING.md index c1ccefe1..c5931fbc 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -30,7 +30,7 @@ behind each constant. | HDR emission and bloom | lit WMO windows, additive models/particles and magma can exceed display white; a soft-capped fullscreen halo is added before the faithful FFX clamp | Advanced Graphics → Bloom (Off/Low/High), cvar `bloom`; Off is the unchanged reference image, suggested High preset value 2 | | Sky quality | Enhanced: the five Light.dbc sky stops through a smooth monotone curve in linear light (no bands at the rings), a soft sun glow tinted by sun and fog colour (fades at night and under cloud), a procedural star field with twinkle and a faint Milky Way over the stock `Stars.m2`. High adds domain-warped cloud detail and sun-lit clouds (self-shadow, silver lining; technique from WarcraftXL, see `THIRD-PARTY.md`). Classic is the reference sky unchanged | Advanced Graphics → Sky Quality, cvar `skyQuality` 0 Classic / 1 Enhanced / 2 High, env `WOW_SKY_QUALITY` | | Modern fog | MONKEY (fog): radial distance fog with a gradual exponential curve, a daylight sun lobe, and an end colour shared by the world, WDL hull, sky horizon and volumetric haze. Each zone keeps its authored fog end up to the reference view-distance limit; optional `LightFogBand.dbc` rows provide height, sun and end-fog controls. Interior WMO fog stays classic | Advanced Graphics → Modern Fog, cvar `fogModel` 0 Classic (default) / 1 Modern (High = 1), env `WOW_FOGMODEL=0\|1`; `fog_hook.wgsl`, `lighting/fog_model.rs`, `light/fog_band.rs` | -| Rain on surfaces | MONKEY (wet): rain gradually darkens and saturates exposed terrain and models, adds puddles and a sky/sun sheen, and produces rings on exterior Enhanced water. Wetness rises over roughly 90 seconds and dries over roughly four minutes; phase 1 has no rain-occlusion map, so shelter is classified only by interior/unlit state and surface facing | Advanced Graphics → Wet Surfaces in Rain, cvar `rainSurfaces` 0/1 (default and High 1), env `WOW_RAIN_SURFACES`, `WOW_WETNESS`, `WOW_WET_T`; `weather/wetness.rs`, `wet_hook.wgsl` | +| Rain on surfaces | MONKEY (wet): rain gradually darkens and saturates exposed terrain and models, adds puddles and a sky/sun sheen, and produces rings on exterior Enhanced water. Wetness rises over roughly 90 seconds and dries over roughly four minutes; MONKEY (rainshelter): a camera-centred rain-occlusion map (128² cells of 0.75 yd, filled by vertical collision rays) keeps ground, walls and water under roofs, porches and bridges dry, with a ~1.5 yd drip margin and a soft edge; tree canopies without colliders do not shelter | Advanced Graphics → Wet Surfaces in Rain, cvar `rainSurfaces` 0/1 (default and High 1), env `WOW_RAIN_SURFACES`, `WOW_WETNESS`, `WOW_WET_T`; `weather/wetness.rs`, `weather/shelter.rs`, `wet_hook.wgsl` | | Foliage wind | one weather-fed gust/veer field drives grass and classified tree/bush foliage; grass also parts around the player and nearby units | Advanced Graphics → Foliage Wind, cvar `foliageWind` 0 Off / 1 Grass / 2 Grass + Trees (High = 2), capture override `WOW_FOLIAGE_WIND`; field and benders in `benilla-world/src/wind/`. The wave phase is the CPU-integrated travel (speed integrated over time, wrapped seamlessly), and the waves' spatial term uses the fixed profile heading; the veer turns only the bend. Tree exile copies sway through material marker bit 14 (`FOLIAGE_WIND_MARKER`). Capture offset `WOW_CAPTURE_WIND_T` (s). Known: shadow casters (world-shadow proxies, `torch_depth.wgsl`) do not apply the sway, so leaves sample a static shadow; a future world-camera DepthPrepass must call the same offsets | | Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` | @@ -90,6 +90,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 From 810cc56df68ac4dd52e3bb97b2e13c8abd351f62 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 19:28:49 +0100 Subject: [PATCH 82/87] MONKEY (integration): RU torch-terrain tooltip newline; RenderSubmesh.stage1 in the billboard-card test Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/assets/ui/OptionsFrame.xml | 4 +--- crates/benilla-formats/tests/m2_billboard_card_face.rs | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 77843a0c..737efbc5 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -846,9 +846,7 @@ .. "Днём не действует и не требует динамического освещения помещений.", TORCH_TERRAIN_SHADOWS = "Земля сама участвует в тенях от огней снаружи: холм или склон между " - .. "костром и местностью за ним заслоняет свет огня. - -" + .. "костром и местностью за ним заслоняет свет огня.\n\n" .. "Добавляет ближний рельеф в карту теней каждого уличного огня при её " .. "перестройке; затраты на кадр невелики. Требует теней огней под " .. "открытым небом.", diff --git a/crates/benilla-formats/tests/m2_billboard_card_face.rs b/crates/benilla-formats/tests/m2_billboard_card_face.rs index 630585e5..eb24a8d8 100644 --- a/crates/benilla-formats/tests/m2_billboard_card_face.rs +++ b/crates/benilla-formats/tests/m2_billboard_card_face.rs @@ -117,6 +117,8 @@ fn the_shape_ignores_edge_on_correct_and_degenerate_batches() { wmo_batch: None, env_map: false, section: None, + // MONKEY (integration): the skybox lane's second texture stage. + stage1: None, }; assert!( From 546706604149ec395acf04095de6d8b0cede85ed Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 19:46:18 +0100 Subject: [PATCH 83/87] MONKEY (integration): LIGHTING.md row for daylightWindowSplit Co-Authored-By: Claude Opus 5.5 --- LIGHTING.md | 1 + 1 file changed, 1 insertion(+) diff --git a/LIGHTING.md b/LIGHTING.md index 07e0b349..838b33e4 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -34,6 +34,7 @@ behind each constant. | 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` | +| City window daylight | MONKEY (fix-daylight/integration): a district WMO's oversized window batch (e.g. the Cathedral of Light's stained-glass walls) is split into window-sized daylight apertures, so the nave takes daylight pane by pane. Applies to WMOs loaded after a change | Advanced Graphics → City Window Daylight, cvar `daylightWindowSplit` 0/1 (default 1, the merged behaviour); `benilla-world/src/lighting/daylight.rs` (`set_window_split`), bridge `benilla-app/src/dynamic_interior.rs` | | Zone skyboxes | the painted sky a zone's `LightParams` names (clear-weather slot, storm slot lerped by the storm blend) for the living, fading in by the Light sphere falloff with the modern client's crossfade (ghost > WMO > zone); extended `LightSkybox.dbc` flags `0x1` day cycle, `0x2` keep sun/moon/stars/clouds, `0x4` fog-colour horizon cone, plus a celestial second model; every skybox now plays its bones, texture transforms, colour and alpha tracks (`benilla-world/src/skybox.rs`, `skybox_anim.rs`, `benilla-formats/src/light/skybox.rs`) | Advanced Graphics → Zone Skyboxes, cvar `zoneSkyboxes` (0 Off default, 1 On; High = 1), env `WOW_ZONE_SKYBOXES=0\|1` | | Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` | From b36848bc6874d919a2875e5b746c39dd61000904 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 20:31:25 +0100 Subject: [PATCH 84/87] MONKEY (integration): order tick_shelter before the lighting resolve set It reads MonkeyFrame.wetness while update_fog_model writes MonkeyFrame; the undeclared pair put the ambiguity count one over the 5,582 ceiling. Co-Authored-By: Claude Opus 5.5 --- crates/benilla-world/src/weather/shelter.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/benilla-world/src/weather/shelter.rs b/crates/benilla-world/src/weather/shelter.rs index adef8eb7..9d0067dd 100644 --- a/crates/benilla-world/src/weather/shelter.rs +++ b/crates/benilla-world/src/weather/shelter.rs @@ -531,7 +531,14 @@ pub(super) fn register(app: &mut App) { app.init_resource::() .init_resource::() .add_plugins(ExtractResourcePlugin::::default()) - .add_systems(Update, tick_shelter.after(super::wetness::wetness_tick)); + // MONKEY (integration): it reads MonkeyFrame.wetness, which the resolve set's fog model also + // writes; run it with wetness, before that set (an undeclared order otherwise). + .add_systems( + Update, + tick_shelter + .after(super::wetness::wetness_tick) + .before(crate::lighting::LightingResolveSet), + ); if let Some(render_app) = app.get_sub_app_mut(RenderApp) { render_app.add_systems( Render, From e970cedcf2c0510583e51151b5d01a14392da493 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 21:53:25 +0100 Subject: [PATCH 85/87] MONKEY (followups): refresh the streamed shadow cache without volumetric fog The static world caster rebuilds only on 16 yd of camera drift. The refresh that invalidates it when streamed geometry arrives lived in the volumetric-fog plugin and was gated on that cvar, so with volumetricFog 0 a standing camera never picked up trees and doodads that streamed in late. The refresh now lives in world_shadow.rs and is gated on worldShadows only (Classic unaffected). Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/volumetric_fog.rs | 48 +------------------- crates/benilla-app/src/world_shadow.rs | 56 ++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 49 deletions(-) diff --git a/crates/benilla-app/src/volumetric_fog.rs b/crates/benilla-app/src/volumetric_fog.rs index b5753771..2d608273 100644 --- a/crates/benilla-app/src/volumetric_fog.rs +++ b/crates/benilla-app/src/volumetric_fog.rs @@ -5,11 +5,9 @@ use crate::{ shadow_core::{ShadowSet, ShadowSun}, video::VideoConfig, - world_shadow::WorldLane, }; use benilla_world::{ lighting::{ResolvedPointLight, ResolvedPointLights, WorldTime, WowLighting}, - static_gx::StaticGx, view::WorldCamera, weather::WeatherState, wmo_portal::CameraInteriorClaim, @@ -119,7 +117,8 @@ impl Plugin for VolumetricFogPlugin { shaft_gain, }) .add_plugins(ExtractComponentPlugin::::default()) - .add_systems(Last, refresh_streamed_shadows.before(ShadowSet::Lanes)) + // MONKEY (followups): the streamed-caster refresh moved to `world_shadow.rs`; it no longer + // depends on this cvar. .add_systems(Last, update_fog.after(ShadowSet::Lanes)); // Keep headless policy tests independent of the renderer. if !app.is_plugin_added::() { @@ -149,27 +148,6 @@ impl Plugin for VolumetricFogPlugin { } } -// The legacy caster cache otherwise misses streamed trees at a stationary camera. -// Off must leave even this old cache behaviour untouched for the exact baseline. -fn refresh_streamed_shadows( - video: Res, - override_value: Res, - gx: Option>, - mut lane: ResMut, - mut seen: Local>, -) { - if override_value.fog.unwrap_or(video.volumetric_fog) == 0 || !video.world_shadows { - *seen = None; - return; - } - let Some(gx) = gx else { return }; - let generation = gx.torch_residency_generation(); - if *seen != Some(generation) { - lane.invalidate_static(); - *seen = Some(generation); - } -} - fn density(minute: f32, weather: f32, indoors: bool) -> f32 { let dawn = (1.0 - ((minute - 390.0) / 90.0).abs()).clamp(0.0, 1.0); let dawn = dawn * dawn * (3.0 - 2.0 * dawn); @@ -704,28 +682,6 @@ fn fragment(@builtin(position) pixel: vec4) -> @location(0) vec4 { mod tests { use super::*; #[test] - fn shadow_cache_refresh_is_gated_and_quiet_when_unchanged() { - let mut app = App::new(); - app.init_resource::() - .init_resource::() - .init_resource::() - .insert_resource(FogOverride { - fog: Some(0), - lamp: None, - shaft_gain: 2.0, - }) - .add_systems(Update, refresh_streamed_shadows); - app.world_mut().clear_trackers(); - app.world_mut().run_schedule(Update); - assert!(!app.world().resource_ref::().is_changed()); - app.world_mut().resource_mut::().fog = Some(1); - app.world_mut().run_schedule(Update); - assert!(app.world().resource_ref::().is_changed()); - app.world_mut().clear_trackers(); - app.world_mut().run_schedule(Update); - assert!(!app.world().resource_ref::().is_changed()); - } - #[test] fn dawn_weather_and_rooms_control_density() { let noon = density(720.0, 0.0, false); assert!(density(390.0, 0.0, false) > noon * 2.0); diff --git a/crates/benilla-app/src/world_shadow.rs b/crates/benilla-app/src/world_shadow.rs index 8e787dce..e04aa5a4 100644 --- a/crates/benilla-app/src/world_shadow.rs +++ b/crates/benilla-app/src/world_shadow.rs @@ -49,10 +49,34 @@ impl Plugin for WorldShadowPlugin { fn build(&self, app: &mut App) { app.add_plugins(MaterialPlugin::::default()) .init_resource::() + // MONKEY (followups): refresh the static caster cache when streamed geometry arrives. + // This used to live in the volumetric-fog plugin and so only ran with that cvar on. + .add_systems(Last, refresh_streamed_shadows.before(ShadowSet::Lanes)) .add_systems(Last, update_world_shadows.in_set(ShadowSet::Lanes)); } } +/// MONKEY (followups): the static caster rebuilds on 16 yd of camera drift, so a standing camera +/// never picks up trees and doodads that stream in later. Invalidate it whenever the retained +/// scene's residency changes, while world shadows are on (Classic has them off, so it is inert). +fn refresh_streamed_shadows( + video: Res, + gx: Option>, + mut lane: ResMut, + mut seen: Local>, +) { + if !video.world_shadows { + *seen = None; + return; + } + let Some(gx) = gx else { return }; + let generation = gx.torch_residency_generation(); + if *seen != Some(generation) { + lane.invalidate_static(); + *seen = Some(generation); + } +} + /// The alpha-tested twin of [`ShadowCasterMaterial`]: same invisible-in-forward proxy, but its /// prepass fragment samples a leaf sheet and discards transparent texels so cutout FOLIAGE casts a /// leaf-shaped silhouette instead of the solid box a positions-only proxy throws. One instance per @@ -106,7 +130,7 @@ struct CutoutCaster { /// The world lane's retained casters. #[derive(Resource, Default)] -// MONKEY (volumetric fog): allow the fog plugin to invalidate a stale streamed caster cache. +// MONKEY (volumetric fog, followups): the streamed-residency refresh invalidates the cache. pub(crate) struct WorldLane { /// The cached static caster — the retained `static_gx` world (trees + buildings), rebuilt on drift. static_caster: Option, @@ -123,8 +147,7 @@ pub(crate) struct WorldLane { env_rate: RebuildRate, } -// MONKEY (volumetric fog): retain ownership of the cache here; the fog plugin decides -// when streamed geometry needs a fresh map, without changing the legacy Off path. +// MONKEY (followups): `refresh_streamed_shadows` decides when streamed geometry needs a fresh map. impl WorldLane { pub(crate) fn invalidate_static(&mut self) { self.static_rebuilt_at = None; @@ -359,3 +382,30 @@ fn teardown( // The mesh the gate was pacing is gone — re-arm so a re-enable builds on its first frame. lane.env_rate.reset(); } + +// MONKEY (followups): the refresh must not depend on volumetric fog. +#[cfg(test)] +mod streamed_refresh_tests { + use super::*; + + #[test] + fn shadow_cache_refresh_runs_without_volumetric_fog_and_is_quiet_when_unchanged() { + let mut app = App::new(); + let mut video = VideoConfig::default(); + video.volumetric_fog = 0; + video.world_shadows = false; + app.insert_resource(video) + .init_resource::() + .init_resource::() + .add_systems(Update, refresh_streamed_shadows); + app.world_mut().clear_trackers(); + app.world_mut().run_schedule(Update); + assert!(!app.world().resource_ref::().is_changed()); + app.world_mut().resource_mut::().world_shadows = true; + app.world_mut().run_schedule(Update); + assert!(app.world().resource_ref::().is_changed()); + app.world_mut().clear_trackers(); + app.world_mut().run_schedule(Update); + assert!(!app.world().resource_ref::().is_changed()); + } +} From 14578ea26bd402b93b9ae9ac1fc676eb11cb9a10 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 21:53:25 +0100 Subject: [PATCH 86/87] MONKEY (followups): re-apply the saved presets to rows missing from config On load, Cvars::reapply_saved_presets writes the saved graphicsQuality rung (and the saved lightingQuality rung, else the Graphics rung's) over every governed row the file does not carry, so a row registered after the save follows the preset instead of its registered default. Rows the file carries and session-owned rows stay; Custom writes nothing. Tests for both ladders. Co-Authored-By: Claude Opus 5.5 --- crates/benilla-app/src/cvars.rs | 163 +++++++++++++++++++++++++++++++- 1 file changed, 162 insertions(+), 1 deletion(-) diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index fd69ed58..2e31b997 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -1806,6 +1806,64 @@ impl Cvars { moved } + /// MONKEY (followups): **re-apply the saved presets to rows the file does not carry.** A saved + /// rung name implies every governed row matched it when the file was written (the label is + /// re-derived every frame), and a row at its registered default is not saved. So a governed + /// row ABSENT from the file is one that was registered after the save (or held a rung value + /// equal to its default): it takes the saved rung's value, not its own registered default. + /// Rows the file carries and session-owned rows are never touched; `Custom` (or no saved + /// name) writes nothing. The lighting rung is the file's own `lightingQuality`, or else the + /// saved Graphics rung's. Returns how many rows moved. + pub(crate) fn reapply_saved_presets(&mut self) -> usize { + let carried = |cvars: &Self, k: &str| { + cvars.file.keys().any(|f| f.eq_ignore_ascii_case(k)) || cvars.is_session_owned(k) + }; + let saved = |cvars: &Self, k: &str| { + if cvars.is_session_owned(k) { + return None; + } + cvars + .file + .iter() + .find(|(f, _)| f.eq_ignore_ascii_case(k)) + .map(|(_, v)| v.clone()) + }; + let mut moved = 0; + let col = saved(self, "graphicsQuality").and_then(|v| graphics_column(v.trim())); + if let Some(col) = col { + for (k, values) in GRAPHICS_PRESETS { + if k.eq_ignore_ascii_case("lightingQuality") || carried(self, k) { + continue; + } + if self.set(k, values[col]) == SetOutcome::Changed { + moved += 1; + } + } + } + let lighting = saved(self, "lightingQuality").or_else(|| { + let col = col?; + GRAPHICS_PRESETS + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("lightingQuality")) + .map(|(_, values)| values[col].to_string()) + }); + let members = lighting.and_then(|name| { + LIGHTING_PRESETS + .iter() + .find(|(n, _)| n.eq_ignore_ascii_case(name.trim())) + .map(|(_, members)| *members) + }); + for (k, v) in members.unwrap_or(&[]) { + if carried(self, k) { + continue; + } + if self.set(k, v) == SetOutcome::Changed { + moved += 1; + } + } + moved + } + /// **The table follows a value the engine already applied** — a mirror, not a write: the /// applied value moves, the config dirties, the VM's mirror learns it, and **no observer /// fires**, because the knob is already there. For a second registered spelling of one knob @@ -2501,7 +2559,11 @@ fn load_config(world: &mut World) { // the file. warn!("{msg}"); } - StoredConfig::Table(table) => cvars.load_file(table), + StoredConfig::Table(table) => { + cvars.load_file(table); + // MONKEY (followups): rows registered after the save follow the saved preset. + cvars.reapply_saved_presets(); + } } // MONKEY (presets): a player's own run (never a capture, never a malformed file) boots // into the default Graphics Preset over whatever its file leaves unsaid. @@ -3552,6 +3614,105 @@ mod tests { assert_eq!(again.get("graphicsQuality"), Some("High")); } + /// MONKEY (followups): a saved Graphics rung fills the governed rows the file lacks (a row + /// registered after the save) with the rung's value; rows the file carries stay; Custom and + /// no saved name write nothing. + #[test] + fn a_saved_graphics_preset_fills_rows_missing_from_the_file() { + // An Ultra save made before `ambientOcclusion` / `lampFog` existed. + let file = BTreeMap::from([ + ("graphicsQuality".to_string(), "Ultra".to_string()), + ("lightingQuality".to_string(), "Ultra".to_string()), + ("farclip".to_string(), "1497".to_string()), + ("zoneSkyboxes".to_string(), "0".to_string()), // the player's own later edit + ]); + let mut cvars = fresh_registry(); + cvars.load_file(file); + assert!(cvars.reapply_saved_presets() > 0); + assert_eq!(cvars.get("ambientOcclusion"), Some("2")); + assert_eq!(cvars.get("lampFog"), Some("2")); + assert_eq!(cvars.get("farclip"), Some("1497")); + assert_eq!(cvars.get("zoneSkyboxes"), Some("0"), "a saved row stays"); + // The lighting members follow Ultra too. + assert_eq!(cvars.get("shadowMapSize"), Some("4096")); + assert_eq!(cvars.get("volumetricFog"), Some("2")); + + // Medium without a saved lighting rung: the Graphics rung's lighting rung applies. + let mut medium = fresh_registry(); + medium.load_file(BTreeMap::from([( + "graphicsQuality".to_string(), + "Medium".to_string(), + )])); + medium.reapply_saved_presets(); + assert_eq!(medium.get("ambientOcclusion"), Some("1")); + assert_eq!(medium.get("farclip"), Some("477")); + lighting_quality(&mut medium); + assert_eq!(medium.get("lightingQuality"), Some("Medium")); + assert_eq!(medium.get("graphicsQuality"), Some("Medium")); + + // Custom writes nothing; neither does a file with no preset name. + for file in [ + BTreeMap::from([("graphicsQuality".to_string(), LIGHTING_CUSTOM.to_string())]), + BTreeMap::from([("skyQuality".to_string(), "1".to_string())]), + ] { + let mut custom = fresh_registry(); + custom.load_file(file); + assert_eq!(custom.reapply_saved_presets(), 0); + assert_eq!( + custom.get("ambientOcclusion"), + custom.default_of("ambientOcclusion") + ); + } + + // A session-owned row is left to the session. + let mut owned = fresh_registry(); + owned.own_for_session("farclip", Some("500")); + owned.load_file(BTreeMap::from([( + "graphicsQuality".to_string(), + "Ultra".to_string(), + )])); + owned.reapply_saved_presets(); + assert_eq!(owned.get("farclip"), Some("500")); + assert_eq!(owned.get("ambientOcclusion"), Some("2")); + } + + /// MONKEY (followups): a saved Lighting Quality rung fills the lighting members the file + /// lacks; members the file carries stay; Custom writes nothing. + #[test] + fn a_saved_lighting_preset_fills_members_missing_from_the_file() { + let mut cvars = fresh_registry(); + cvars.load_file(BTreeMap::from([ + ("lightingQuality".to_string(), "Ultra".to_string()), + ("volumetricFog".to_string(), "1".to_string()), + ])); + assert!(cvars.reapply_saved_presets() > 0); + assert_eq!(cvars.get("shadowMapSize"), Some("4096")); + assert_eq!(cvars.get("interiorShadowCasters"), Some("16")); + assert_eq!(cvars.get("volumetricFog"), Some("1"), "a saved member stays"); + // No Graphics rung saved: the graphics rows keep their defaults. + assert_eq!( + cvars.get("ambientOcclusion"), + cvars.default_of("ambientOcclusion") + ); + + let mut off = fresh_registry(); + off.load_file(BTreeMap::from([( + "lightingQuality".to_string(), + "Off".to_string(), + )])); + off.reapply_saved_presets(); + assert_eq!(off.get("worldShadows"), Some("0")); + lighting_quality(&mut off); + assert_eq!(off.get("lightingQuality"), Some("Off")); + + let mut custom = fresh_registry(); + custom.load_file(BTreeMap::from([( + "lightingQuality".to_string(), + LIGHTING_CUSTOM.to_string(), + )])); + assert_eq!(custom.reapply_saved_presets(), 0); + } + /// The seed never overrides the player: a saved preset stops it outright, a saved row keeps /// its value, and a session-owned row (an env lever) is left to the session. #[test] From f7715c80af83410e1a0830cc0bd532d63ce041fe Mon Sep 17 00:00:00 2001 From: pkuzic Date: Fri, 25 Sep 2026 22:02:01 +0100 Subject: [PATCH 87/87] MONKEY (followups): reduce SSAO grain on alpha-tested foliage STAGE_AO flags a two-sided depth spike (the smaller one-sided step two pixels out, capped by the second difference: ~0 on planes, grazing planes, silhouettes and contact creases) and stores the smoothness in alpha. STAGE_BLUR turns the spike density of its 4x4 window into a foliage mask, widens the depth tolerance there (up to 11x) and scales the occlusion by 1 - 0.4 * mask. Smooth windows are unchanged. WOW_AO_DEBUG=5 shows the mask. LIGHTING.md documents this and the two earlier followups fixes. Co-Authored-By: Claude Opus 5.5 --- LIGHTING.md | 10 ++++-- crates/benilla-app/src/shaders/ssao.wgsl | 39 +++++++++++++++++++++--- crates/benilla-app/src/ssao.rs | 10 +++--- 3 files changed, 47 insertions(+), 12 deletions(-) diff --git a/LIGHTING.md b/LIGHTING.md index 70b76924..ff447c35 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -15,7 +15,7 @@ behind each constant. | Lane | What you see | Main switch | |---|---|---| | Sun shadows, characters | real silhouettes for units instead of the oval blob | `characterShadows` | -| Sun shadows, world | trees, buildings and alpha-tested foliage cast; baked terrain shadows step aside | `worldShadows` | +| Sun shadows, world | trees, buildings and alpha-tested foliage cast; baked terrain shadows step aside; the static caster cache also refreshes when streamed geometry arrives, so a standing camera picks up late trees (`world_shadow.rs::refresh_streamed_shadows`, independent of volumetric fog) | `worldShadows` | | Dynamic interiors | WMO rooms are lit per fragment by their own fixtures with a soft falloff, gated per room so light does not leak through walls | `interiorLight`, `interiorRoomGate` | | Synthesised lights | torches, braziers, lanterns, campfires and lampposts emit a light in the colour of their flame, with flicker | `fireLightGain`, `fireFlicker` | | Torch shadows | cube-map shadows from point lights onto buildings, models and terrain, with a contact-hardening penumbra and a shadow floor | `interiorShadows`, `exteriorShadows`, `torchShadowStrength`, `interiorShadowSoft` | @@ -32,7 +32,7 @@ behind each constant. | 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; 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 | +| 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; on alpha-tested foliage (a dense window of two-sided depth spikes) the blur widens its depth tolerance and the AO drops 40 %, which removes the leaf grain while planes, silhouettes and contact creases keep full AO (`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..5` diagnostic views (factor, protection, distance, raw occlusion, foliage mask), `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` | | City window daylight | MONKEY (fix-daylight/integration): a district WMO's oversized window batch (e.g. the Cathedral of Light's stained-glass walls) is split into window-sized daylight apertures, so the nave takes daylight pane by pane. Applies to WMOs loaded after a change | Advanced Graphics → City Window Daylight, cvar `daylightWindowSplit` 0/1 (default 1, the merged behaviour); `benilla-world/src/lighting/daylight.rs` (`set_window_split`), bridge `benilla-app/src/dynamic_interior.rs` | | Zone skyboxes | the painted sky a zone's `LightParams` names (clear-weather slot, storm slot lerped by the storm blend) for the living, fading in by the Light sphere falloff with the modern client's crossfade (ghost > WMO > zone); extended `LightSkybox.dbc` flags `0x1` day cycle, `0x2` keep sun/moon/stars/clouds, `0x4` fog-colour horizon cone, plus a celestial second model; every skybox now plays its bones, texture transforms, colour and alpha tracks (`benilla-world/src/skybox.rs`, `skybox_anim.rs`, `benilla-formats/src/light/skybox.rs`) | Advanced Graphics → Zone Skyboxes, cvar `zoneSkyboxes` (0 Off default, 1 On; High = 1), env `WOW_ZONE_SKYBOXES=0\|1` | @@ -77,6 +77,12 @@ column at startup (`Cvars::seed_graphics_preset`) on every row the file does not registered defaults stay their per-lane ones (`farclip` the reference's 350), and captures and tests, which never seed, keep them. The Advanced page's Defaults button lands on High too. +**Saved presets and new rows** (MONKEY (followups)). On load, `Cvars::reapply_saved_presets` +writes the saved `graphicsQuality` rung (and the saved `lightingQuality` rung, else the Graphics +rung's) over every governed row the file does NOT carry, so a row registered after the save gets +the preset's value, not its registered default. Rows the file carries and session-owned rows stay; +`Custom` writes nothing. + **Render Distance** is `farclip`, the same cvar as the Graphics page's Terrain Distance (which keeps the stock 177–777 slider). Its clamp (`view::FARCLIP_RANGE`) now reaches `FARCLIP_MAX` = 1497 yd, the 1.12 grid of 60 carried on. Terrain residency (`terrain_stream/window.rs`), the diff --git a/crates/benilla-app/src/shaders/ssao.wgsl b/crates/benilla-app/src/shaders/ssao.wgsl index c3459723..6b5b9bb9 100644 --- a/crates/benilla-app/src/shaders/ssao.wgsl +++ b/crates/benilla-app/src/shaders/ssao.wgsl @@ -5,7 +5,8 @@ // STAGE_BLUR half resolution: 4x4 depth-aware box; the 4x4 Bayer rotation cancels inside it. // STAGE_APPLY full resolution: joint-bilateral upsample, output multiplied into the scene. // Stored texel: r = occlusion 0..1 (fade applied), g = view distance (SKY_DIST for sky), -// b = bright-pixel protection 0..1. +// b = bright-pixel protection 0..1, a (MONKEY (followups)) = surface smoothness 0..1 after +// STAGE_AO (0 where both neighbours on an axis jump in depth), 1 - foliage mask after STAGE_BLUR. #import bevy_render::view::View struct Ao { @@ -13,7 +14,7 @@ struct Ao { params: vec4, // Distance fade start and end in yards, protection luma ramp start and end. fade: vec4, - // x: debug view (0 off, 1 factor, 2 protection, 3 distance/50, 4 raw occlusion); y: gain. + // x: debug view (0 off, 1 factor, 2 protection, 3 distance/50, 4 raw occlusion, 5 smoothness); y: gain. debug: vec4, } @@ -99,6 +100,20 @@ fn ao_main(@builtin(position) frag: vec4) -> @location(0) vec4 { let dy = select(p - pu, pd - p, abs(pd.z - p.z) < abs(p.z - pu.z)); var n = normalize(cross(dx, dy)); if (dot(n, p) > 0.0) { n = -n; } + // MONKEY (followups): cutout leaves scatter depth spikes through a canopy, and there the taps + // are noise. The spike is the smaller of the two one-sided steps two pixels out, capped by the + // second difference, which is ~0 on any plane (even at grazing angles) and on a silhouette + // (one side continuous). Contact creases stay smooth, so contact shadows keep full strength. + // The blur turns the spike DENSITY of its window into the foliage mask. + let ax = neighbour(f + vec2(2, 0), p).z - p.z; + let bx = p.z - neighbour(f - vec2(2, 0), p).z; + let ay = neighbour(f + vec2(0, 2), p).z - p.z; + let by = p.z - neighbour(f - vec2(0, 2), p).z; + let spike = max( + min(min(abs(ax), abs(bx)), abs(ax - bx)), + min(min(abs(ay), abs(by)), abs(ay - by)), + ); + let smooth_surface = 1.0 - smoothstep(0.01, 0.04, spike / dist); let count = u32(ao.params.z); let rot = (bayer4(h) + 0.5) * (6.2831853 / 16.0); @@ -119,7 +134,7 @@ fn ao_main(@builtin(position) frag: vec4) -> @location(0) vec4 { occ += w * max(0.0, dot(v, n) * inverseSqrt(vv + 1e-6) - ao.params.w); } occ = saturate(ao.debug.y * occ / f32(count)) * fade; - return vec4(occ, dist, protect, 1.0); + return vec4(occ, dist, protect, smooth_surface); } #endif @@ -130,7 +145,17 @@ fn blur_main(@builtin(position) frag: vec4) -> @location(0) vec4 { let size = vec2(textureDimensions(raw)); let c = textureLoad(raw, h, 0); if (c.g >= SKY_DIST) { return c; } - let tolerance = 0.04 * c.g + 0.05; + // MONKEY (followups): foliage = spike density over the window. There the depth test would + // reject every leaf neighbour and keep the grain, so the tolerance widens and the leftover + // occlusion is scaled down; a window of smooth surfaces keeps the tight edge and full AO. + var spikes = 0.0; + for (var y = -2; y <= 1; y += 1) { + for (var x = -2; x <= 1; x += 1) { + spikes += 1.0 - textureLoad(raw, clamp(h + vec2(x, y), vec2(0), size - vec2(1)), 0).a; + } + } + let foliage = saturate(spikes / 16.0 * 4.0); + let tolerance = (0.04 * c.g + 0.05) * (1.0 + 10.0 * foliage); var sum = 0.0; var weight = 0.0; for (var y = -2; y <= 1; y += 1) { @@ -141,7 +166,7 @@ fn blur_main(@builtin(position) frag: vec4) -> @location(0) vec4 { weight += w; } } - return vec4(sum / max(weight, 1e-4), c.g, c.b, 1.0); + return vec4(sum / max(weight, 1e-4) * (1.0 - 0.4 * foliage), c.g, c.b, 1.0 - foliage); } #endif @@ -162,6 +187,7 @@ fn apply_main(@builtin(position) frag: vec4) -> @location(0) vec4 { let t = hc - floor(hc); var occ = 0.0; var protect = 0.0; + var smooth_surface = 0.0; var weight = 0.0; for (var j = 0; j < 4; j += 1) { let o = vec2(j & 1, j >> 1u); @@ -170,6 +196,7 @@ fn apply_main(@builtin(position) frag: vec4) -> @location(0) vec4 { let w = b.x * b.y / (0.02 + abs(s.g - dist) / dist) + 1e-5; occ += s.r * w; protect += s.b * w; + smooth_surface += s.a * w; weight += w; } occ /= weight; @@ -178,6 +205,8 @@ fn apply_main(@builtin(position) frag: vec4) -> @location(0) vec4 { // Red = positive view distance, green = negative (a sign error), both / 50 yd. if (mode == 3u) { return vec4(saturate(dist / 50.0), saturate(-dist / 50.0), 0.0, 1.0); } if (mode == 4u) { return vec4(1.0 - occ, 1.0 - occ, 1.0 - occ, 1.0); } + // MONKEY (followups): 5 = foliage mask (black = treated as foliage). + if (mode == 5u) { let m = smooth_surface / weight; return vec4(m, m, m, 1.0); } let k = 1.0 - ao.params.y * occ * (1.0 - protect); return vec4(k, k, k, 1.0); } diff --git a/crates/benilla-app/src/ssao.rs b/crates/benilla-app/src/ssao.rs index c997cc96..a7803742 100644 --- a/crates/benilla-app/src/ssao.rs +++ b/crates/benilla-app/src/ssao.rs @@ -10,7 +10,7 @@ //! bright pixels (lit windows, flames, sunlit sand) are protected; fade 45-90 yd (Low) / 60-120 yd (High). //! //! cvar `ambientOcclusion`: 0 Off (pass not scheduled, image unchanged), 1 Low, 2 High. -//! `WOW_AO=0|1|2` overrides it for the session; `WOW_AO_DEBUG=1..4` writes a diagnostic view +//! `WOW_AO=0|1|2` overrides it for the session; `WOW_AO_DEBUG=1..5` writes a diagnostic view //! (factor, protection, distance, raw occlusion); `WOW_AO_GAIN/RADIUS/BIAS/STRENGTH` tune it. use crate::video::VideoConfig; use benilla_world::{liquid::WaterDepthLabel, view::WorldCamera}; @@ -50,8 +50,8 @@ struct AoView { params: Vec4, /// Distance fade start and end (yd), bright-pixel protection ramp (gamma max channel). fade: Vec4, - /// x = debug view (0 off, 1 AO factor, 2 protection, 3 distance/50, 4 raw occlusion), - /// y = occlusion gain. + /// x = debug view (0 off, 1 AO factor, 2 protection, 3 distance/50, 4 raw occlusion, + /// 5 surface smoothness, MONKEY (followups)), y = occlusion gain. debug: Vec4, } @@ -87,11 +87,11 @@ impl AoView { } } -/// `WOW_AO_DEBUG=1..4`: write a diagnostic term instead of darkening the scene. +/// `WOW_AO_DEBUG=1..5`: write a diagnostic term instead of darkening the scene. fn debug_mode() -> u8 { static MODE: std::sync::OnceLock = std::sync::OnceLock::new(); *MODE.get_or_init(|| { - std::env::var("WOW_AO_DEBUG").ok().and_then(|v| v.parse().ok()).unwrap_or(0).min(4) + std::env::var("WOW_AO_DEBUG").ok().and_then(|v| v.parse().ok()).unwrap_or(0).min(5) }) }