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
+
+
+
+
+
+
+
+ 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