Merge gfx/post into integration

Preserve p0 fog and MonkeyFrame hooks, water's row-view ABI, and the post lane's emissive, bloom, sun-shaft, grading, cvar, and options work.

Co-Authored-By: GPT-5.6 Sol (Codex)
This commit is contained in:
pkuzic
2026-09-25 08:01:24 +01:00
co-authored by GPT-5.6 Sol (Codex)
28 changed files with 1693 additions and 13 deletions
+2
View File
@@ -25,6 +25,7 @@ behind each constant.
| 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` |
| 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 /
@@ -40,6 +41,7 @@ is its own module, documented in `WATER.md`.
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-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.
+2 -2
View File
@@ -31,8 +31,8 @@ where they stand. Do not remove either.
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (beach surf, `on_bed`) | `wxl-experimental-water` | `sea/Shore.hpp`, `shaders/Shore.hlsli` | Shore surf limited by the terrain column, so objects standing in the water never foam (principle) |
| `crates/benilla-assets/src/shaders/enhanced_water.wgsl` (Gerstner crests, High) + `crates/benilla-world/src/liquid/waves.rs` | `wxl-experimental-water` | `shaders/Surface.ps.hlsl`, `sea/Spectrum.*`, `sea/Ocean.*` | Gerstner displacement and fold-driven crest whitecaps; the CPU swimmer sample inverts the same displacement (ported) |
| `crates/benilla-world/src/water_fx/bob.rs` | `wxl-experimental-water` | `world/Ride.cpp` | Visual-only wave riding for swimmers; the authoritative position never moves (principle, implemented independently) |
| `crates/benilla-app/src/post/grading.rs` + `crates/benilla-app/src/post/grading.wgsl` | `wxl-retail-grading` | `Grading.*`, `shaders/Grading.ps.hlsl` | 32³ LUT convention, strength control and strip lookup mathematics, with the strip uploaded as a native 3D texture (ported from the GPL-3.0-or-later source implementation, Copyright (C) 2026 WarcraftXL) |
Planned (not yet in the tree): breaker index from `sea/Shore.hpp`, `shaders/Wave.hlsli`; grass wind from `wxl-experimental-wind`
(`field/Wind.*`, `grass/GrassWind.*`); colour grading from `wxl-retail-grading`
(`Grading.*`, `shaders/Grading.ps.hlsl`); cloud sheets from `wxl-retail-clouds` (`Clouds.*`).
(`field/Wind.*`, `grass/GrassWind.*`); cloud sheets from `wxl-retail-clouds` (`Clouds.*`).
The lane that ports one adds its row here.
+92
View File
@@ -0,0 +1,92 @@
# benilla-water
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 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
(`Classic`) is untouched and stays byte-identical to upstream; the enhanced path only runs when the
player picks it, and every part of it can be switched off.
## Credits
- **Original project: [WarcraftXL](https://github.com/WarcraftXL)** — the `wxl-experimental-water`
3.3.5a client extension.
- **Author: iThorgrim.**
The WarcraftXL water module is the design reference for this one, and its author has given
permission to reuse its code here on the condition that the author and the original project are
named. This section is that attribution; it must travel with the module (this file, and the
`enhanced_water.wgsl` header) wherever the module is copied or shared.
Rule for contributors: a file that ports code or a technique from WarcraftXL says so in its header
(`Ported from WarcraftXL (https://github.com/WarcraftXL) by iThorgrim — module <module>, <source
files>.`; blocks inside a file keep their `Portions derived from WarcraftXL ...` line), the
technique is listed in the table below, and the file is listed in
[`THIRD-PARTY.md`](THIRD-PARTY.md), the one table of every WarcraftXL-derived file across the
fork. Do not remove any of them.
| Technique | Status here | From WarcraftXL |
|---|---|---|
| Visual-only wave riding for swimmers (authoritative position never moves) | shipped (`water_fx/bob.rs`) | same principle as `world/Ride.cpp`, implemented independently |
| Shore surf limited by the TERRAIN column, so objects in the water never foam | shipped (authored depth gate, `on_bed`) | principle from `sea/Shore.hpp` |
| Per-channel extinction + scene-copy refraction | shipped (`enhanced_water.wgsl` DEPTH LOOK; bent samples landing on anything in front of the water are refused via the scene depth) | ported from `shaders/Surface.ps.hlsl`, `render/Refraction.cpp` |
| Caustics, two layers at incommensurate scales/rates | shipped (floor web; built from animated cell edges, not value noise) | ported from `shaders/Surface.ps.hlsl`, `render/Noise.cpp` |
| Screen-space reflection of the scenery | shipped on `High` (a bisected march against the scene depth, not WXL's single probe) | principle from `shaders/Surface.ps.hlsl` |
| Gerstner trains + breaker index, crest-fold foam | planned | `sea/Spectrum.*`, `sea/Shore.hpp`, `shaders/Wave.hlsli` |
| Specular lobe widened by the normal's screen derivative, distance glints | shipped / planned | `shaders/Surface.ps.hlsl` |
## What it does
| Lane | What you see | Main switch |
|---|---|---|
| Quality tiers | `Classic` = the reference water, `Enhanced`, `High` (= Enhanced + scenery reflections) | Video options → Water Quality, cvar `waterQuality`, env `WOW_WATER=0\|1\|2` |
| Procedural waves | multi-band analytic waves with exact normals; the long swell moves ocean vertices | tier |
| Open-sea whitecaps | High adds Gerstner crest gathering and fold-thresholded foam; calm canals remain clear | tier |
| Near mesh refinement | High builds a transient 4x liquid lattice within 64 yd, with an 80 yd release ring | 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 |
| 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.
## Where the code lives
- **Shader**: `crates/benilla-assets/src/shaders/enhanced_water.wgsl` - the whole module on the GPU
side, an importable library (`benilla::enhanced_water`) with its own bindings: 103 scene depth,
104 `WaterParams` (`benilla_assets::WaterUniform`), 105 the shared light buffer (a second
read-only view of upstream's binding 90), 106 the scene colour (for refraction and reflection). Upstream's `liquid.wgsl` carries three hooks only: the
`#import`, `water_swell` in its vertex stage, and the `water_active()` branch at the top of its
fragment stage, which returns the module's result as-is (the module fogs its own surface terms
only: the scene it refracts and reflects is already fogged). Keep it that way: new
water code goes in the module, not in `liquid.wgsl`.
- **Assets** (`crates/benilla-assets/src`): `water_depth.rs` (`WaterUniform`, the scene-depth image),
`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 and the opt-in `WOW_WATER_PROBE` WMO classification/depth log),
`liquid/lod.rs` (High-only 4x near-grid swap),
`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`.
## Invariants worth knowing before editing
- `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.
- The temporary water wind is `WATER_WIND_DIR`; all wind-aligned shader terms derive from it until
the weather lane supplies shared wind.
- Run one GPU capture at a time, and run `cargo test` in a shell WITHOUT `WOW_DATA` exported.
+63 -2
View File
@@ -494,6 +494,10 @@
lightingQuality = "Lighting Quality",
-- MONKEY (volumetric fog): localised atmospheric quality row.
volumetricFog = "Volumetric Fog",
-- MONKEY (post): HDR excess + halo quality.
bloom = "Bloom",
sunShafts = "Sun Shafts",
colorGrading = "Colour Grading",
fogOff = "Off",
waterQuality = "Water Quality",
waterClassic = "Classic",
@@ -534,6 +538,13 @@
.. "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.",
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 =
@@ -622,6 +633,10 @@
ruRU = {
page = "Расширенная графика",
lightingQuality = "Качество освещения",
-- MONKEY (post): Russian HDR excess + halo quality row.
bloom = "Свечение",
sunShafts = "Солнечные лучи",
colorGrading = "Цветокоррекция",
-- MONKEY (volumetric fog): Russian atmospheric quality row.
volumetricFog = "Объёмный туман",
fogOff = "Выкл",
@@ -665,6 +680,13 @@
.. "сохраняют ваши значения. «Низкое», «Среднее» и «Высокое» используют улучшенную воду; "
.. "зеркальные отражения включаются отдельно. Изменение настройки набора показывает «Своё», "
.. "если ни один набор больше не совпадает.",
-- MONKEY (post): localised bloom effect and Off contract.
BLOOM =
"Добавляет мягкое сияние огню, лаве, заклинаниям и освещённым окнам. Низкое качество использует короткое размытие, высокое — более плавное. Выключение сохраняет исходное изображение.",
SUN_SHAFTS =
"Показывает солнечные лучи сквозь деревья и здания. Рельеф и здания перекрывают свет; ночью и в помещениях эффект исчезает.",
COLOR_GRADING =
"Применяет мягкую цветовую палитру зоны и плавно меняет дневной вариант на ночной. Интерфейс не изменяется.",
-- MONKEY (volumetric fog): localised effect and cost explanation.
VOLUMETRIC_FOG =
"Выкл отключает объёмный туман. Низкое добавляет дымку вблизи и лучи солнца или луны с меньшим числом шагов; Высокое использует больше шагов для более плавных лучей. Туман гуще на рассвете и в непогоду, а в помещениях слабее. Для лучей нужны тени; без теней остаётся только дымка.",
@@ -808,7 +830,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", "RowSunShafts", "RowColorGrading",
"RowCharacterShadows", "RowWorldShadows", "RowShadowDistance",
"RowShadowResolution", "RowMoonShadows",
"RowInteriorLight", "RowInteriorShadows", "RowExteriorShadows", "RowTorchSoft",
@@ -2617,11 +2639,50 @@
})
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowCharacterShadows" inherits="OptionsCheckboxRowTemplate">
<!-- MONKEY (post): live HDR/bloom tier; Off is the baseline. -->
<Frame name="$parentRowBloom" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowVolumetricFog" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowVolumetricFog" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
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" },
})
</OnLoad></Scripts>
</Frame>
<!-- MONKEY (post): depth-occluded radial sunlight, world only. -->
<Frame name="$parentRowSunShafts" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowBloom" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowBloom" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "sunShafts", BENILLA_ADVGFX.sunShafts,
"BENILLA_TOOLTIP_SUN_SHAFTS")
</OnLoad></Scripts>
</Frame>
<!-- MONKEY (post): zone/day-night 32-cube grade, world only. -->
<Frame name="$parentRowColorGrading" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowSunShafts" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowSunShafts" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "colorGrading", BENILLA_ADVGFX.colorGrading,
"BENILLA_TOOLTIP_COLOR_GRADING")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowCharacterShadows" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<!-- MONKEY (post): screen effects sit immediately above this one. -->
<Anchor point="TOPLEFT" relativeTo="$parentRowColorGrading" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowColorGrading" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "characterShadows",
BENILLA_ADVGFX.characterShadows,
@@ -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
+32 -4
View File
@@ -928,6 +928,10 @@ pub(crate) const REGISTERED: &[Registered] = &[
"0",
"benilla's own: faint screen dither against sky and fog banding, 0 Off / 1 On",
),
// 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",
@@ -2015,6 +2019,10 @@ 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"),
("sunShafts", "0"),
("colorGrading", "0"),
("lavaLightGain", "0"),
("fireLightGain", "0"),
("nightGain", "1.0"),
@@ -2038,6 +2046,10 @@ 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"),
("sunShafts", "0"),
("colorGrading", "0"),
("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
@@ -2064,6 +2076,10 @@ 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"),
("sunShafts", "0"),
("colorGrading", "0"),
("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
@@ -2091,6 +2107,10 @@ 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"),
("sunShafts", "1"),
("colorGrading", "1"),
("lavaLightGain", "1"),
("fireLightGain", "1"),
("nightGain", "0.45"),
@@ -2905,10 +2925,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); 33] = [
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)),
@@ -2961,10 +2985,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 — 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(), 33, "the lighting lane welds 33 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),
@@ -3140,6 +3164,10 @@ 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,
"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,
@@ -3997,7 +4025,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
+9 -1
View File
@@ -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<MoonShadowStrength>,
mut water: ResMut<WaterQuality>,
mut lava: ResMut<LavaLightGain>,
// MONKEY (post): bloom also arms the HDR source hooks through the shared light blob.
mut emissive: ResMut<EmissiveTier>,
) {
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::<MoonShadowStrength>()
.init_resource::<WaterQuality>()
.init_resource::<LavaLightGain>()
.init_resource::<EmissiveTier>()
.add_plugins(DynamicInteriorPlugin);
assert_eq!(app.world().resource::<WaterQuality>().0, VideoConfig::default().water_quality);
assert_eq!(app.world().resource::<LavaLightGain>().0, VideoConfig::default().lava_light_gain);
+2
View File
@@ -247,6 +247,8 @@ impl PluginGroup for GamePlugins {
.add(crate::volumetric_fog::VolumetricFogPlugin)
// MONKEY (p0 graphics programme): the programme's cvar bridges (skyDither, ...).
.add(crate::monkey_gfx::MonkeyGfxPlugin)
// 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)
+3 -1
View File
@@ -72,11 +72,13 @@ 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;
// MONKEY (p0 graphics programme): the programme's cvar bridges (skyDither, ...).
mod monkey_gfx;
mod dynamic_interior;
// MONKEY (post): optional world-only HDR post-processing.
mod post;
mod entities;
mod fishing_line;
mod footprints;
+366
View File
@@ -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::<BloomView>::default())
.add_systems(Last, update_views);
if !app.is_plugin_added::<AssetPlugin>() {
return;
}
let shader = app
.world_mut()
.resource_mut::<Assets<Shader>>()
.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::<ViewNodeRunner<BloomNode>>(Core3d, BloomLabel)
.add_render_graph_edges(
Core3d,
(
Node3d::StartMainPassPostProcessing,
BloomLabel,
FfxGlowLabel,
),
);
}
}
fn update_views(
mut commands: Commands,
video: Res<VideoConfig>,
cameras: Query<(Entity, &Camera, Option<&BloomView>), With<WorldCamera>>,
) {
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::<BloomView>();
}
} else if old.is_none_or(|old| old.tier != tier) {
commands.entity(entity).insert(BloomView { tier });
}
}
}
#[derive(Resource)]
struct BloomShader(Handle<Shader>);
#[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<RenderDevice>,
fullscreen: Res<FullscreenShader>,
shader: Res<BloomShader>,
pipeline_cache: Res<PipelineCache>,
) {
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<TextureCache>,
render_device: Res<RenderDevice>,
pipeline_cache: Res<PipelineCache>,
pipelines: Res<BloomPipelines>,
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::<BloomPipelines>();
let cache = world.resource::<PipelineCache>();
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::<RenderQueue>().write_buffer(
&textures.uniform,
0,
bytemuck::cast_slice(&params),
);
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(())
}
}
+68
View File
@@ -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<f32>;
@group(0) @binding(1) var linear_sampler: sampler;
@group(0) @binding(2) var auxiliary_tex: texture_2d<f32>;
struct BloomParams {
tier: f32,
gain: f32,
unused: vec2<f32>,
};
@group(0) @binding(3) var<uniform> bloom: BloomParams;
fn tap(tex: texture_2d<f32>, uv: vec2<f32>, offset: vec2<f32>) -> vec4<f32> {
let size = vec2<f32>(textureDimensions(tex));
return textureSampleLevel(tex, linear_sampler, uv + offset / size, 0.0);
}
@fragment
fn fs_extract(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
// 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<f32>, axis: vec2<f32>) -> vec4<f32> {
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<f32> {
return blur_axis(in.uv, vec2(1.0, 0.0));
}
@fragment
fn fs_blur_v(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
return blur_axis(in.uv, vec2(0.0, 1.0));
}
@fragment
fn fs_combine(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
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);
}
+432
View File
@@ -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<String, Handle<Image>>,
identity: Handle<Image>,
}
#[derive(Component, Clone, PartialEq, ExtractComponent)]
struct GradeView {
day: Handle<Image>,
night: Handle<Image>,
// 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::<GradeView>::default())
.add_systems(Last, update_views);
if !app.is_plugin_added::<AssetPlugin>() {
return;
}
app.add_systems(Startup, load_catalog.after(AssetSet::Open));
let shader = app
.world_mut()
.resource_mut::<Assets<Shader>>()
.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::<ViewNodeRunner<GradeNode>>(Core3d, GradeLabel)
.add_render_graph_edges(
Core3d,
(
BloomLabel,
GradeLabel,
benilla_world::ffx_glow::FfxGlowLabel,
),
);
}
}
fn load_catalog(
mut commands: Commands,
assets: Option<Res<WorldAssets>>,
mut images: ResMut<Assets<Image>>,
) {
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<String> = 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<Vec<u8>> {
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<u8> {
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<u8>) -> 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<VideoConfig>,
catalog: Option<Res<GradeCatalog>>,
area: Res<CurrentArea>,
areas: Option<Res<crate::area::AreaTableRes>>,
time: Res<WorldTime>,
cameras: Query<(Entity, &Camera, Option<&GradeView>), With<WorldCamera>>,
) {
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::<GradeView>();
}
_ => {}
}
}
}
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<Shader>);
#[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<RenderDevice>,
cache: Res<PipelineCache>,
shader: Res<GradeShader>,
fullscreen: Res<FullscreenShader>,
) {
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::<GradeUniform>(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::<GradePipeline>();
let cache = world.resource::<PipelineCache>();
let Some(pipeline) = cache.get_render_pipeline(settings.pipeline) else {
return Ok(());
};
let images = world.resource::<RenderAssets<GpuImage>>();
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::<RenderQueue>());
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);
}
}
+35
View File
@@ -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<f32>;
@group(0) @binding(1) var scene_sampler: sampler;
@group(0) @binding(2) var day_lut: texture_3d<f32>;
@group(0) @binding(3) var night_lut: texture_3d<f32>;
@group(0) @binding(4) var lut_sampler: sampler;
struct Grade {
control: vec4<f32>,
};
@group(0) @binding(5) var<uniform> grade: Grade;
fn grade_one(lut: texture_3d<f32>, color: vec3<f32>) -> vec3<f32> {
// 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<f32> {
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);
}
+15
View File
@@ -0,0 +1,15 @@
//! MONKEY (post): optional world-camera effects ahead of FFXGlow's gamma clamp.
mod bloom;
mod grading;
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(grading::GradingPlugin);
app.add_plugins(sun_shafts::SunShaftsPlugin);
}
}
+315
View File
@@ -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::<ShaftView>::default())
.add_systems(Last, update_views);
if !app.is_plugin_added::<AssetPlugin>() {
return;
}
let shader = app
.world_mut()
.resource_mut::<Assets<Shader>>()
.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::<SpecializedRenderPipelines<ShaftPipeline>>()
.add_systems(RenderStartup, init_pipeline)
.add_systems(Render, prepare_pipelines.in_set(RenderSystems::Prepare))
.add_render_graph_node::<ViewNodeRunner<ShaftNode>>(Core3d, ShaftLabel)
.add_render_graph_edges(
Core3d,
(Node3d::StartMainPassPostProcessing, ShaftLabel, BloomLabel),
);
}
}
fn update_views(
mut commands: Commands,
video: Res<VideoConfig>,
lighting: Res<WowLighting>,
interior: Res<CameraInteriorClaim>,
mut cameras: Query<
(
Entity,
&Camera,
&GlobalTransform,
&Projection,
&mut Camera3d,
Option<&ShaftView>,
),
With<WorldCamera>,
>,
) {
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::<ShaftView>();
}
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::<ShaftView>();
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::<ShaftView>();
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<Shader>);
#[derive(Resource)]
struct ShaftPipeline {
layouts: [BindGroupLayoutDescriptor; 2],
shader: Handle<Shader>,
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<RenderDevice>,
shader: Res<ShaftShader>,
fullscreen: Res<FullscreenShader>,
) {
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::<ShaftView>(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<PipelineCache>,
pipeline: Res<ShaftPipeline>,
mut specialized: ResMut<SpecializedRenderPipelines<ShaftPipeline>>,
views: Query<(Entity, &ViewTarget, &Msaa), With<ShaftView>>,
) {
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::<PipelineCache>();
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::<ShaftPipeline>();
let device = context.render_device();
let mut uniform = UniformBuffer::from(*shaft);
uniform.write_buffer(device, world.resource::<RenderQueue>());
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);
}
}
@@ -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<f32>;
@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<f32>,
color: vec4<f32>,
};
@group(0) @binding(3) var<uniform> shafts: Shafts;
fn scene_depth(uv: vec2<f32>) -> f32 {
let dims = textureDimensions(depth);
let p = vec2<i32>(clamp(uv, vec2(0.0), vec2(0.999999)) * vec2<f32>(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<f32> {
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);
}
+16
View File
@@ -222,6 +222,12 @@ 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,
// 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,
@@ -467,6 +473,11 @@ impl Default for VideoConfig {
water_quality: 1,
// MONKEY (volumetric fog): default to the inexpensive atmosphere.
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,
color_grading: true,
lava_light_gain: 1.0,
fire_flicker: 1.0,
display: if windowed_env() {
@@ -547,6 +558,11 @@ 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,
// 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(),
+2
View File
@@ -34,6 +34,8 @@ pub fn register_shaders(app: &mut App) {
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 (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");
}
@@ -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<f32>,
kind: u32,
packed_lane: f32,
authored_weight: f32,
) -> vec3<f32> {
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);
}
@@ -34,6 +34,8 @@
#import benilla::monkey_frame
// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
#import benilla::fog_hook
// 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<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(101) var frames_samp: sampler;
@@ -237,7 +239,12 @@ fn fragment(in: LiquidVsOut) -> @location(0) vec4<f32> {
// 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<f32>(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<f32>(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`,
@@ -24,6 +24,8 @@
#import benilla::monkey_frame
// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
#import benilla::fog_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.
@@ -1743,6 +1745,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
+4
View File
@@ -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::{
@@ -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<MonkeyZoneGrade>,
}
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<Item = &MonkeyZoneGrade> {
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<MonkeyZoneGrades> {
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<MonkeyZoneGrades> {
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 })
}
+2 -1
View File
@@ -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)]
@@ -477,6 +477,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
@@ -645,6 +650,8 @@ pub(super) fn register(app: &mut App) {
.init_resource::<DynamicInteriors>()
// MONKEY (fire GO lights): the live gain on synthesised fire lights.
.init_resource::<FireLightGain>()
// MONKEY (post): opt-in HDR emission, packed into an existing float lane.
.init_resource::<EmissiveTier>()
// MONKEY (spellLightGain): and the one on spell-effect lights, which overrides it.
.init_resource::<SpellLightGain>()
.init_resource::<super::prop_probes::PropProbeExtract>()
@@ -1309,6 +1316,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<DynamicInteriors>,
// MONKEY (post): the 0/1/2 tier rides the unused `light_diffuse.w` marker.
emissive: Res<EmissiveTier>,
// MONKEY (fire GO lights): the live gain on synthesised fire lights (0 = the lane off).
fire_gain: Res<FireLightGain>,
// MONKEY (spellLightGain): and the spell lane's own, which overrides it on a spell row.
@@ -1376,6 +1385,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.
@@ -2060,6 +2071,8 @@ mod tests {
.init_resource::<ShadowHandover>()
.init_resource::<DynamicInteriors>()
.init_resource::<FireLightGain>()
// MONKEY (post): the packer reads the live HDR tier too.
.init_resource::<EmissiveTier>()
.init_resource::<SpellLightGain>()
.add_systems(Update, build_light_data);
app.world_mut()
+2
View File
@@ -53,6 +53,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,
};
@@ -14,6 +14,8 @@
#import benilla::monkey_frame
// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
#import benilla::fog_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).
@@ -1986,6 +1988,12 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
rgb = fog_hook::apply_fog(rgb, fog_color.xyz, fog_span, eye_z, in.world_position.xyz,
view.world_position, true, wow_light.monkey);
}
// 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<f32>(rgb, 1.0);
}
@@ -16,6 +16,8 @@
#import benilla::monkey_frame
// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
#import benilla::fog_hook
// 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 {
@@ -173,7 +175,9 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
}
#ifdef BLEND_ADD
// Premultiplied in gamma, so stacked quads sum like the reference's bytes.
return vec4<f32>(rgb * c.a, 0.0);
// MONKEY (post): boost before the additive framebuffer blend, never after the stack.
return vec4<f32>(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<f32>(rgb, 1.0);