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:
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(¶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(())
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user