diff --git a/LIGHTING.md b/LIGHTING.md index bb879614..a14a3221 100644 --- a/LIGHTING.md +++ b/LIGHTING.md @@ -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. diff --git a/THIRD-PARTY.md b/THIRD-PARTY.md index 2d816d8e..e4f9982e 100644 --- a/THIRD-PARTY.md +++ b/THIRD-PARTY.md @@ -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. diff --git a/WATER.md b/WATER.md index e69de29b..ce61136c 100644 --- a/WATER.md +++ b/WATER.md @@ -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 , .`; 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. diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index a54f95cb..06760570 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -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 @@ }) - + + + + 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, "sunShafts", BENILLA_ADVGFX.sunShafts, + "BENILLA_TOOLTIP_SUN_SHAFTS") + + + + + + + + + + OptionsRow_OnLoad(self, "colorGrading", BENILLA_ADVGFX.colorGrading, + "BENILLA_TOOLTIP_COLOR_GRADING") + + + + + + + + OptionsRow_OnLoad(self, "characterShadows", BENILLA_ADVGFX.characterShadows, diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs index d970a9fd..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 b865aa5d..36365679 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -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 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/game_plugins.rs b/crates/benilla-app/src/game_plugins.rs index ec16c3c0..d35052db 100644 --- a/crates/benilla-app/src/game_plugins.rs +++ b/crates/benilla-app/src/game_plugins.rs @@ -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) diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index cece103d..cf78a51f 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -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; 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/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 new file mode 100644 index 00000000..2952e5a6 --- /dev/null +++ b/crates/benilla-app/src/post/mod.rs @@ -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); + } +} 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 07c17824..e440d6ac 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -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(), diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs index a2254aa8..248e663e 100644 --- a/crates/benilla-assets/src/materials.rs +++ b/crates/benilla-assets/src/materials.rs @@ -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"); } 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 2ff2ab65..c414754b 100644 --- a/crates/benilla-assets/src/shaders/liquid.wgsl +++ b/crates/benilla-assets/src/shaders/liquid.wgsl @@ -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; @group(#{MATERIAL_BIND_GROUP}) @binding(101) var frames_samp: sampler; @@ -237,7 +239,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 c7c9dcb4..e4de91be 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -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 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 }) +} diff --git a/crates/benilla-world/src/ffx_glow.rs b/crates/benilla-world/src/ffx_glow.rs index 6d6bf6b3..9cd244b5 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)] diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs index 06d5d90b..fd86a368 100644 --- a/crates/benilla-world/src/lighting/global_light.rs +++ b/crates/benilla-world/src/lighting/global_light.rs @@ -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::() // 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::() @@ -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, + // 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. @@ -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::() .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 81f80ad3..5f0ab8c9 100644 --- a/crates/benilla-world/src/lighting/mod.rs +++ b/crates/benilla-world/src/lighting/mod.rs @@ -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, }; diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl index a626e709..244787bb 100644 --- a/crates/benilla-world/src/shaders/static_gx.wgsl +++ b/crates/benilla-world/src/shaders/static_gx.wgsl @@ -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 { 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(rgb, 1.0); } diff --git a/crates/benilla-world/src/shaders/wow_effect.wgsl b/crates/benilla-world/src/shaders/wow_effect.wgsl index ed16930b..d210f998 100644 --- a/crates/benilla-world/src/shaders/wow_effect.wgsl +++ b/crates/benilla-world/src/shaders/wow_effect.wgsl @@ -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 { } #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);