From 36d03ee462eb04a2bd318d37c8819bb03dc29f19 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 02:02:19 +0100
Subject: [PATCH 1/4] MONKEY (post): route emissive materials into HDR
Co-Authored-By: GPT-5.6 Sol (Codex)
---
LIGHTING.md | 2 ++
WATER.md | 1 +
crates/benilla-app/assets/ui/OptionsFrame.xml | 31 ++++++++++++++--
crates/benilla-app/src/cvars.rs | 6 ++++
crates/benilla-app/src/dynamic_interior.rs | 10 +++++-
crates/benilla-app/src/video.rs | 6 ++++
crates/benilla-assets/src/materials.rs | 2 ++
.../src/shaders/emissive_hook.wgsl | 36 +++++++++++++++++++
crates/benilla-assets/src/shaders/liquid.wgsl | 9 ++++-
.../benilla-assets/src/shaders/wow_model.wgsl | 5 +++
.../src/lighting/global_light.rs | 13 +++++++
crates/benilla-world/src/lighting/mod.rs | 2 ++
.../benilla-world/src/shaders/static_gx.wgsl | 8 +++++
.../benilla-world/src/shaders/wow_effect.wgsl | 6 +++-
14 files changed, 132 insertions(+), 5 deletions(-)
create mode 100644 crates/benilla-assets/src/shaders/emissive_hook.wgsl
diff --git a/LIGHTING.md b/LIGHTING.md
index 2122d3df..f1852454 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` |
+| HDR emission and bloom | lit WMO windows, additive models/particles and magma can exceed display white; a soft-capped fullscreen halo is added before the faithful FFX clamp | Advanced Graphics → Bloom (Off/Low/High), cvar `bloom`; Off is the unchanged reference image, suggested High preset value 2 |
| 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 /
@@ -35,6 +36,7 @@ is its own module, documented in `WATER.md`.
## Where the code lives
- **Shaders**: `crates/benilla-assets/src/shaders/shadow_hook.wgsl` (shared shadow sampling),
+ `crates/benilla-assets/src/shaders/emissive_hook.wgsl` (shared opt-in HDR multipliers),
`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/WATER.md b/WATER.md
index 9ee06550..4938656a 100644
--- a/WATER.md
+++ b/WATER.md
@@ -46,6 +46,7 @@ the technique is listed in the table below. Do not remove either.
| Beach surf | a travelling wave front, a lace of foam that dissolves behind it, a swash sheet on the sand | tier |
| Swimmers | wake foam, treading rings and a visual-only bob with the swell | tier |
| Lava glow | magma surfaces emit warm point lights through the lighting module | Advanced Graphics → Lava Glow, `lavaLightGain` |
+| Lava HDR | magma partially resists distance fog and feeds the screen-space halo | Advanced Graphics → Bloom, `bloom` (Off/Low/High) |
Rivers deliberately have NO flow effect: a derived current was tried and removed at the owner's
request. If it ever returns it should read the authored MCLQ flow records, not derive one.
diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 71e19e82..bc03017f 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -494,6 +494,8 @@
lightingQuality = "Lighting Quality",
-- MONKEY (volumetric fog): localised atmospheric quality row.
volumetricFog = "Volumetric Fog",
+ -- MONKEY (post): HDR excess + halo quality.
+ bloom = "Bloom",
fogOff = "Off",
waterQuality = "Water Quality",
waterClassic = "Classic",
@@ -532,6 +534,9 @@
.. "Low, Medium and High use Enhanced water; mirror reflections stay opt-in. "
.. "Changing a preset member shows Custom when no preset matches.",
-- MONKEY (volumetric fog): explain cost and the shadow-map dependency.
+ -- MONKEY (post): Off guarantees the pre-HDR image.
+ BLOOM =
+ "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.",
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 =
@@ -616,6 +621,8 @@
ruRU = {
page = "Расширенная графика",
lightingQuality = "Качество освещения",
+ -- MONKEY (post): Russian HDR excess + halo quality row.
+ bloom = "Свечение",
-- MONKEY (volumetric fog): Russian atmospheric quality row.
volumetricFog = "Объёмный туман",
fogOff = "Выкл",
@@ -657,6 +664,9 @@
.. "сохраняют ваши значения. «Низкое», «Среднее» и «Высокое» используют улучшенную воду; "
.. "зеркальные отражения включаются отдельно. Изменение настройки набора показывает «Своё», "
.. "если ни один набор больше не совпадает.",
+ -- MONKEY (post): localised bloom effect and Off contract.
+ BLOOM =
+ "Добавляет мягкое сияние огню, лаве, заклинаниям и освещённым окнам. Низкое качество использует короткое размытие, высокое — более плавное. Выключение сохраняет исходное изображение.",
-- MONKEY (volumetric fog): localised effect and cost explanation.
VOLUMETRIC_FOG =
"Выкл отключает объёмный туман. Низкое добавляет дымку вблизи и лучи солнца или луны с меньшим числом шагов; Высокое использует больше шагов для более плавных лучей. Туман гуще на рассвете и в непогоду, а в помещениях слабее. Для лучей нужны тени; без теней остаётся только дымка.",
@@ -795,7 +805,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",
+ "RowLightingQuality", "RowWaterQuality", "RowVolumetricFog", "RowBloom",
"RowCharacterShadows", "RowWorldShadows", "RowShadowDistance",
"RowShadowResolution", "RowMoonShadows",
"RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft",
@@ -2602,11 +2612,28 @@
})
-
+
+
+
+ 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 2/4] 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 5e61bf676dd5406c434ff904e19b1d27353ffc08 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 02:38:43 +0100
Subject: [PATCH 3/4] MONKEY (post): add depth-occluded sun shafts
Co-Authored-By: GPT-5.6 Sol (Codex)
---
crates/benilla-app/assets/ui/OptionsFrame.xml | 23 +-
crates/benilla-app/src/cvars.rs | 15 +-
crates/benilla-app/src/post/mod.rs | 2 +
crates/benilla-app/src/post/sun_shafts.rs | 315 ++++++++++++++++++
crates/benilla-app/src/post/sun_shafts.wgsl | 53 +++
crates/benilla-app/src/video.rs | 6 +
6 files changed, 407 insertions(+), 7 deletions(-)
create mode 100644 crates/benilla-app/src/post/sun_shafts.rs
create mode 100644 crates/benilla-app/src/post/sun_shafts.wgsl
diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index bc03017f..e8b7b0fa 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -496,6 +496,7 @@
volumetricFog = "Volumetric Fog",
-- MONKEY (post): HDR excess + halo quality.
bloom = "Bloom",
+ sunShafts = "Sun Shafts",
fogOff = "Off",
waterQuality = "Water Quality",
waterClassic = "Classic",
@@ -537,6 +538,8 @@
-- MONKEY (post): Off guarantees the pre-HDR image.
BLOOM =
"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.",
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 =
@@ -623,6 +626,7 @@
lightingQuality = "Качество освещения",
-- MONKEY (post): Russian HDR excess + halo quality row.
bloom = "Свечение",
+ sunShafts = "Солнечные лучи",
-- MONKEY (volumetric fog): Russian atmospheric quality row.
volumetricFog = "Объёмный туман",
fogOff = "Выкл",
@@ -667,6 +671,8 @@
-- MONKEY (post): localised bloom effect and Off contract.
BLOOM =
"Добавляет мягкое сияние огню, лаве, заклинаниям и освещённым окнам. Низкое качество использует короткое размытие, высокое — более плавное. Выключение сохраняет исходное изображение.",
+ SUN_SHAFTS =
+ "Показывает солнечные лучи сквозь деревья и здания. Рельеф и здания перекрывают свет; ночью и в помещениях эффект исчезает.",
-- MONKEY (volumetric fog): localised effect and cost explanation.
VOLUMETRIC_FOG =
"Выкл отключает объёмный туман. Низкое добавляет дымку вблизи и лучи солнца или луны с меньшим числом шагов; Высокое использует больше шагов для более плавных лучей. Туман гуще на рассвете и в непогоду, а в помещениях слабее. Для лучей нужны тени; без теней остаётся только дымка.",
@@ -805,7 +811,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",
+ "RowLightingQuality", "RowWaterQuality", "RowVolumetricFog", "RowBloom", "RowSunShafts",
"RowCharacterShadows", "RowWorldShadows", "RowShadowDistance",
"RowShadowResolution", "RowMoonShadows",
"RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft",
@@ -2628,12 +2634,23 @@
})
-
+
+
-
+
+ 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 4/4] 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 })
+}