Merge gfx/fog into integration

# Conflicts:
#	LIGHTING.md
#	crates/benilla-app/assets/ui/OptionsFrame.xml
#	crates/benilla-app/src/cvars.rs
#	crates/benilla-app/src/monkey_gfx.rs
#	crates/benilla-assets/src/shaders/enhanced_water.wgsl
#	crates/benilla-assets/src/shaders/fog_hook.wgsl
#	crates/benilla-assets/src/shaders/monkey_frame.wgsl
#	crates/benilla-world/src/lighting/monkey_frame.rs
#	crates/benilla-world/src/shaders/sky.wgsl
#	crates/benilla-world/src/sky.rs
This commit is contained in:
pkuzic
2026-09-25 11:59:43 +01:00
18 changed files with 909 additions and 36 deletions
+2 -1
View File
@@ -29,6 +29,7 @@ behind each constant.
| 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 |
| Sky quality | Enhanced: the five Light.dbc sky stops through a smooth monotone curve in linear light (no bands at the rings), a soft sun glow tinted by sun and fog colour (fades at night and under cloud), a procedural star field with twinkle and a faint Milky Way over the stock `Stars.m2`. High adds domain-warped cloud detail and sun-lit clouds (self-shadow, silver lining; technique from WarcraftXL, see `THIRD-PARTY.md`). Classic is the reference sky unchanged | Advanced Graphics → Sky Quality, cvar `skyQuality` 0 Classic / 1 Enhanced / 2 High, env `WOW_SKY_QUALITY` |
| Modern fog | MONKEY (fog): radial distance fog with a gradual exponential curve, a daylight sun lobe, and an end colour shared by the world, WDL hull, sky horizon and volumetric haze. Each zone keeps its authored fog end up to the reference view-distance limit; optional `LightFogBand.dbc` rows provide height, sun and end-fog controls. Interior WMO fog stays classic | Advanced Graphics → Modern Fog, cvar `fogModel` 0 Classic (default) / 1 Modern (High = 1), env `WOW_FOGMODEL=0\|1`; `fog_hook.wgsl`, `lighting/fog_model.rs`, `light/fog_band.rs` |
| Foliage wind | one weather-fed gust/veer field drives grass and classified tree/bush foliage; grass also parts around the player and nearby units | Advanced Graphics → Foliage Wind, cvar `foliageWind` 0 Off / 1 Grass / 2 Grass + Trees (High = 2), capture override `WOW_FOLIAGE_WIND`; field and benders in `benilla-world/src/wind/` |
| Night and interior level | global dimming of the night sky term and of interior ambient | `nightGain`, `interiorGain`, `interiorBakeFloor` |
@@ -81,7 +82,7 @@ is its own module, documented in `WATER.md`.
| `fog_a` | height_fog_density | height_fog_height | height_fog_falloff | curve_blend |
| `fog_b` | sun_fog r | g | b | sun_fog_strength |
| `fog_c` | end_fog r | g | b | end_fog_distance |
| `fog_d` | fog_model (0 classic, 1 modern) | sun_fog_angle | 0 | 0 |
| `fog_d` | fog_model (0 classic; Modern = scene fog end in yd) | sun_fog_angle | sun direction octahedral x | y |
| `wind_a` | dir_x | dir_y | speed | gust |
| `wind_b` | time_s | sway_strength | grass_strength | tree_strength |
| `wet_a` | rain_rate | wetness | ripple_time_s | snow |
+36 -4
View File
@@ -528,6 +528,8 @@
interiorDarkness = "Interior Darkness",
-- MONKEY (p0 skyDither)
skyDither = "Sky Dithering",
-- MONKEY (fog)
fogModel = "Modern Fog",
-- MONKEY (wind)
foliageWind = "Foliage Wind",
windGrass = "Grass",
@@ -648,7 +650,14 @@
SKY_DITHER =
"Adds a faint noise to the final image so smooth sky and fog gradients do not "
.. "break into visible bands. Off is the original client's look. Practically free.",
-- MONKEY (wind)
-- MONKEY (fog)
FOG_MODEL =
"Softer distance fog in the style of the modern client: it thickens gradually "
.. "instead of in a straight ramp, glows toward the sun by day and shifts to the "
.. "sky's colour in the distance, so the land melts into the horizon. Each zone "
.. "keeps its own fog distance; above the original view-distance limit the fog "
.. "recedes with it.\n\nOff is the original client's fog. Practically free.",
-- MONKEY (wind)
FOLIAGE_WIND =
"Grass bends in two travelling wind waves and parts around nearby characters. "
.. "Grass + Trees also gives classified tree and bush foliage a gentle sway.\n\n"
@@ -694,6 +703,8 @@
interiorDarkness = "Темнота помещений",
-- MONKEY (p0 skyDither)
skyDither = "Дизеринг неба",
-- MONKEY (fog)
fogModel = "Современный туман",
-- MONKEY (wind)
foliageWind = "Ветер листвы",
windGrass = "Трава",
@@ -824,7 +835,14 @@
"Добавляет едва заметный шум в итоговое изображение, чтобы плавные переходы цвета "
.. "неба и тумана не распадались на полосы. Выключено — вид оригинального клиента. "
.. "Почти ничего не стоит.",
-- MONKEY (wind)
-- MONKEY (fog)
FOG_MODEL =
"Более мягкий туман в духе современного клиента: он сгущается постепенно, а не "
.. "по прямой, днём светлеет в сторону солнца, а вдали принимает цвет неба, "
.. "так что земля растворяется в горизонте. Дальность тумана в каждой зоне "
.. "прежняя; за пределом дальности обзора оригинала туман отступает вместе с ней."
.. "\n\nВыключено — туман оригинального клиента. Почти ничего не стоит.",
-- MONKEY (wind)
FOLIAGE_WIND =
"Трава гнётся двумя бегущими волнами ветра и расступается вокруг персонажей. "
.. "Трава + деревья также добавляет мягкое качание листвы деревьев и кустов.\n\n"
@@ -895,6 +913,8 @@
"RowNightDarkness", "RowInteriorDarkness",
-- MONKEY (p0 skyDither)
"RowSkyDither",
-- MONKEY (fog)
"RowFogModel",
-- MONKEY (wind): appended for conflict-free lane merges.
"RowFoliageWind",
},
@@ -3005,12 +3025,24 @@
"BENILLA_TOOLTIP_SKY_DITHER")
</OnLoad></Scripts>
</Frame>
<!-- MONKEY (wind): three foliage tiers; appended at the end of the page. -->
<Frame name="$parentRowFoliageWind" inherits="OptionsDropdownRowTemplate">
<!-- MONKEY (fog): the distance-fog model. -->
<Frame name="$parentRowFogModel" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowSkyDither" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowSkyDither" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "fogModel",
BENILLA_ADVGFX.fogModel,
"BENILLA_TOOLTIP_FOG_MODEL")
</OnLoad></Scripts>
</Frame>
<!-- MONKEY (wind): three foliage tiers; appended at the end of the page. -->
<Frame name="$parentRowFoliageWind" inherits="OptionsDropdownRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowFogModel" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowFogModel" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsRow_OnLoad(self, "foliageWind",
BENILLA_ADVGFX.foliageWind,
@@ -1592,6 +1592,41 @@ pub(super) const ON_DEMAND: &[Scenario] = &[
// a world-image instrument, and UI script errors must not cover the grass it measures.
ui: None,
},
// MONKEY (fog): Duskwood's short zone fog (Light 15, LightParams 14) from above the canopy west
// of Darkshire toward the town, for the Classic/Modern fog A/B (`WOW_FOGMODEL=0|1`).
Scenario {
name: "fog-duskwood-noon",
map: Some(MAP_AZEROTH),
eye: [-10700.0, -900.0, 160.0],
look: [-10560.0, -1180.0, 60.0],
minute: 720,
ui: None,
},
// MONKEY (fog): a long Elwynn vista from the Northshire ridge south over Goldshire's forest.
Scenario {
name: "fog-vista-noon",
map: Some(MAP_AZEROTH),
eye: [-9000.0, -100.0, 200.0],
look: [-9500.0, -100.0, 90.0],
minute: 720,
ui: None,
},
Scenario {
name: "fog-vista-dusk",
map: Some(MAP_AZEROTH),
eye: [-9000.0, -100.0, 200.0],
look: [-9500.0, -100.0, 90.0],
minute: 1170,
ui: None,
},
Scenario {
name: "fog-duskwood-dusk",
map: Some(MAP_AZEROTH),
eye: [-10700.0, -900.0, 160.0],
look: [-10560.0, -1180.0, 60.0],
minute: 1170,
ui: None,
},
];
/// MONKEY (p0 baseline): the Elwynn programme view, above the canopy east of Goldshire looking
+10
View File
@@ -944,6 +944,14 @@ pub(crate) const REGISTERED: &[Registered] = &[
"2",
"benilla's own: foliage wind, 0 Off / 1 Grass / 2 Grass + trees",
),
// MONKEY (fog): the distance-fog model. 0 = the 1.12 linear fog (byte-identical), 1 = Modern
// (exponential, sun/horizon colour, end-fog shift, fog end decoupled from farclip past 777).
// Default 0; the Graphics preset's High sets 1.
ours(
"fogModel",
"0",
"benilla's own: distance fog, 0 Classic (1.12 linear) / 1 Modern (soft, sun-coloured horizon)",
),
ours(
"waterQuality",
"1",
@@ -3781,6 +3789,8 @@ mod tests {
// MONKEY (p0 graphics programme)
|app| {
app.init_resource::<benilla_world::ffx_glow::SkyDither>();
// MONKEY (fog)
app.init_resource::<benilla_world::lighting::FogModelSetting>();
app.add_observer(crate::monkey_gfx::on_cvar);
},
|app| {
+7
View File
@@ -6,8 +6,10 @@
//! |---|---|---|---|---|
//! | `skyDither` | 0 Off / 1 On | 0 | 1 | `benilla_world::ffx_glow::SkyDither` |
//! | `foliageWind` | 0 Off / 1 Grass / 2 Grass + trees | 2 | 2 | `benilla_world::wind::FoliageWind` |
//! | `fogModel` | 0 Classic / 1 Modern | 0 | 1 | `benilla_world::lighting::FogModelSetting` |
use benilla_world::ffx_glow::SkyDither;
use benilla_world::lighting::FogModelSetting;
use benilla_world::wind::FoliageWind;
use bevy::prelude::*;
@@ -16,6 +18,8 @@ pub(crate) fn on_cvar(
ev: On<crate::cvars::CvarChanged>,
mut dither: ResMut<SkyDither>,
mut foliage_wind: ResMut<FoliageWind>,
// MONKEY (fog)
mut fog_model: ResMut<FogModelSetting>,
) {
match ev.key().as_str() {
"skydither" => {
@@ -37,6 +41,8 @@ pub(crate) fn on_cvar(
foliage_wind.0 = want;
}
}
// MONKEY (fog): `WOW_FOGMODEL` still wins for the session (captures).
"fogmodel" => fog_model.set_from_cvar(ev.num()),
_ => {}
}
}
@@ -49,6 +55,7 @@ impl Plugin for MonkeyGfxPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<SkyDither>()
.init_resource::<FoliageWind>()
.init_resource::<FogModelSetting>()
.add_observer(on_cvar);
}
}
+28 -3
View File
@@ -52,6 +52,11 @@ struct FogView {
sun_strength: Vec4,
// Actual celestial direction and raymarch step count.
direction_steps: Vec4,
// MONKEY (fog): MonkeyFrame fog rows 1-3. Modern (fog3.x > 0) colours the haze with the shared
// fog colour function and keeps sky pixels (depth 0) out of the distance haze.
mf_fog1: Vec4,
mf_fog2: Vec4,
mf_fog3: Vec4,
}
impl Plugin for VolumetricFogPlugin {
@@ -138,6 +143,8 @@ fn update_fog(
clock: Res<WorldTime>,
weather: Res<WeatherState>,
interior: Res<CameraInteriorClaim>,
// MONKEY (fog): the Modern fog rows (Option: absent in unit tests).
monkey: Option<Res<benilla_world::lighting::MonkeyFrame>>,
mut cameras: Query<(Entity, &Camera, &mut Camera3d), With<WorldCamera>>,
suns: Query<(Entity, &DirectionalLight), With<ShadowSun>>,
) {
@@ -161,7 +168,12 @@ fn update_fog(
let sun = lighting.celestial_dir();
let daylight = ((sun.y + 0.02) / 0.15).clamp(0.0, 1.0);
let fog = Vec3::from_array(lighting.fog_color);
// MONKEY (fog): rows 1-3 as packed for the light buffer.
let rows = monkey.as_ref().map_or([[0.0; 4]; 16], |m| m.pack(0.0, 0.0));
commands.entity(entity).insert(FogView {
mf_fog1: Vec4::from_array(rows[1]),
mf_fog2: Vec4::from_array(rows[2]),
mf_fog3: Vec4::from_array(rows[3]),
color_density: fog.extend(
density(
clock.minute_f,
@@ -371,6 +383,7 @@ impl ViewNode for FogNode {
const FOG_SHADER: &str = r#"
#import bevy_render::view::View
#import bevy_pbr::mesh_view_types::Lights
#import benilla::fog_hook
@group(0) @binding(0) var scene: texture_2d<f32>;
#ifdef MULTISAMPLED
@group(0) @binding(1) var depth: texture_depth_multisampled_2d;
@@ -381,7 +394,11 @@ const FOG_SHADER: &str = r#"
@group(0) @binding(3) var<uniform> lights: Lights;
@group(0) @binding(4) var shadows: texture_depth_2d_array;
@group(0) @binding(5) var shadow_sampler: sampler_comparison;
struct Fog { color_density: vec4<f32>, sun_strength: vec4<f32>, direction_steps: vec4<f32> }
struct Fog {
color_density: vec4<f32>, sun_strength: vec4<f32>, direction_steps: vec4<f32>,
// MONKEY (fog): MonkeyFrame fog1..fog3.
mf_fog1: vec4<f32>, mf_fog2: vec4<f32>, mf_fog3: vec4<f32>,
}
@group(0) @binding(6) var<uniform> fog: Fog;
// Integral of smoothstep(10, 30, distance): zero in the first ten yards,
@@ -420,9 +437,17 @@ fn fragment(@builtin(position) pixel: vec4<f32>) -> @location(0) vec4<f32> {
if (distance <= 10.0) { return source; }
let ray = normalize(delta);
let optical_depth = fog.color_density.w * fog_path(distance);
let opacity = 1.0 - exp(-optical_depth);
var opacity = 1.0 - exp(-optical_depth);
var haze_rgb = fog.color_density.rgb;
if (fog_hook::fog_is_modern(fog.mf_fog3)) {
// MONKEY (fog): the sky (depth 0, clamped to 150 yd above) already carries its own
// horizon fog, so it takes no distance haze; the haze colour is the shared fog colour.
if (z <= 0.0) { opacity = 0.0; }
haze_rgb = fog_hook::fog_modern_colour(fog.color_density.rgb, ray, length(delta),
fog.mf_fog1, fog.mf_fog2, fog.mf_fog3);
}
let luma = vec3(0.2126, 0.7152, 0.0722);
var haze = mix(source.rgb, fog.color_density.rgb, opacity);
var haze = mix(source.rgb, haze_rgb, opacity);
// A darker zone colour may change hue, never the pixel's luminance. Add
// neutral headroom proportionally so saturated channels cannot clip dark.
let missing = max(0.0, dot(source.rgb - haze, luma));
+112 -3
View File
@@ -15,6 +15,21 @@
//
// Extension point for the FOG lane: `fog_sample` branches on `mf.fog_d.x` (`MonkeyFrame.fog_model`,
// 0 classic / 1 modern). The modern arm currently returns the classic result.
//
// MONKEY (fog): the Modern model (cvar `fogModel` 1; CPU half `lighting/fog_model.rs`). `fog_d.x`
// holds the scene fog end when Modern is on (0 = Classic, every path below is then untouched):
// - survival: radial distance, exponential from the zone's start, fitted so 60 % survives at the
// middle of the 1.12 span, closed linearly over the last stretch (from max(start, 0.3·end), the
// modern client's 1/0.7 end fade) so the fog still ends where the zone authored it; optional
// height fog below `fog_a.y` (Bevy Y); `fog_a.w` blends back toward the 1.12 linear ramp.
// - colour (scene colour only, never the black/white/grey blend policies): shifts to the end-fog
// colour `fog_c` by (d / end)³, then leans to the sun-fog colour `fog_b` inside a cubic lobe around
// the sun (cosine threshold `fog_d.y`, direction octahedral in `fog_d.zw`; strength already faded
// for night on the CPU).
// - only spans ending at the scene fog end take it: an interior WMO fog pair stays classic.
// - the WDL hull's own (0, 1) pair asks for the horizon: fully fogged, in the far colour.
// The sky dome (`fog_sky_horizon`) and the volumetric haze (`fog_modern_colour`) call the same
// colour function, so the fogged world, the far hull and the sky's horizon meet in one colour.
#import benilla::monkey_frame::MonkeyFrame
@@ -24,6 +39,86 @@ fn fog_linear(eye_z: f32, span: vec2<f32>) -> f32 {
return clamp((span.y - eye_z) / denom, 0.0, 1.0);
}
// MONKEY (fog): exp survival at the span midpoint = 0.6, i.e. k·(end − start) = −2·ln 0.6.
const FOG_EXP_MID: f32 = 1.0216512;
// MONKEY (fog): the unit vector packed by `fog_model::oct_encode`.
fn fog_oct_decode(e: vec2<f32>) -> vec3<f32> {
var v = vec3<f32>(e.x, 1.0 - abs(e.x) - abs(e.y), e.y);
let t = max(-v.y, 0.0);
v.x += select(t, -t, v.x >= 0.0);
v.z += select(t, -t, v.z >= 0.0);
return normalize(v);
}
// MONKEY (fog): whether the Modern model is on for this frame.
fn fog_is_modern(sel_row: vec4<f32>) -> bool {
return sel_row.x > 0.5;
}
// MONKEY (fog): the Modern fog colour seen along unit `dir` at radial distance `d` (yd), from the
// scene fog colour `base`: end-fog shift, then the sun lobe. `d` may be huge (the sky).
fn fog_modern_colour(
base: vec3<f32>,
dir: vec3<f32>,
d: f32,
sun_row: vec4<f32>,
end_row: vec4<f32>,
sel_row: vec4<f32>,
) -> vec3<f32> {
var c = base;
if (end_row.w > 0.0) {
let t = clamp(d / end_row.w, 0.0, 1.0);
c = mix(c, end_row.rgb, t * t * t);
}
if (sun_row.w > 0.0) {
let sun = fog_oct_decode(sel_row.zw);
let a = sel_row.y;
let s = clamp((dot(dir, sun) - a) / max(1.0 - a, 0.001), 0.0, 1.0);
c = mix(c, sun_row.rgb, sun_row.w * s * s * s);
}
return c;
}
// MONKEY (fog): the Modern survival (1 = no fog) at radial distance `d` for the pair `span`.
fn fog_modern_survival(span: vec2<f32>, d: f32, world_y: f32, height_row: vec4<f32>) -> f32 {
let len = max(span.y - span.x, 0.001);
let k = FOG_EXP_MID / len;
let x = max(d - span.x, 0.0);
var expo = exp(-k * x);
if (height_row.x > 0.0) {
// Extra density at and below the plane, fading out `1 / falloff` yd above it.
let hf = clamp(1.0 - (world_y - height_row.y) * height_row.z, 0.0, 1.0);
expo = mix(expo, exp(-k * (1.0 + height_row.x) * x), hf);
}
let fade_from = max(span.x, 0.3 * span.y);
let end_fade = clamp((span.y - d) / max(span.y - fade_from, 0.001), 0.0, 1.0);
let linear = clamp((span.y - d) / len, 0.0, 1.0);
return mix(min(expo, end_fade), linear, clamp(height_row.w, 0.0, 1.0));
}
// MONKEY (fog): the sky dome's low band under Modern. `col` is the dome's own colour at `dir`,
// `horizon_rgb` the colour it uses at 0° (the scene fog colour); the far fog colour along `dir`
// replaces the horizon and eases into the dome's own gradient by 15° of elevation (a mix, never an
// offset, so an already warm dusk ring is not pushed past itself).
// Classic returns `col` untouched.
fn fog_sky_horizon(
col: vec3<f32>,
horizon_rgb: vec3<f32>,
dir: vec3<f32>,
sun_row: vec4<f32>,
end_row: vec4<f32>,
sel_row: vec4<f32>,
) -> vec3<f32> {
if (!fog_is_modern(sel_row)) {
return col;
}
let far = fog_modern_colour(horizon_rgb, dir, 1.0e9, sun_row, end_row, sel_row);
let elev = asin(clamp(dir.y, -1.0, 1.0));
let w = 1.0 - smoothstep(0.0, 0.26179939, elev);
return mix(col, far, w);
}
// The fog a surface stands in: rgb = the fog colour, w = how much of the surface survives (1 = no
// fog). `fog_rgb` is the colour the caller's policy picked; `scene_colour` is true when that is the
// scene's own fog colour (false for the forced black/white/grey policies of additive, Mod and Mod2x
@@ -40,9 +135,23 @@ fn fog_sample(
) -> vec4<f32> {
let classic = vec4<f32>(fog_rgb, fog_linear(eye_z, span));
if (mf.fog_d.x > 0.5) {
// FOG lane: the modern model (height / sun / end-colour / curve fog) goes here, reading
// mf.fog_a..fog_d. Until it lands, the modern arm is the classic law.
return classic;
// MONKEY (fog): the Modern model (header).
let delta = world_pos - camera_pos;
let d = length(delta);
let dir = delta / max(d, 0.0001);
if (span.x == 0.0 && span.y == 1.0) {
// The WDL hull's own pair: the horizon, fully fogged in the far colour.
return vec4<f32>(fog_modern_colour(fog_rgb, dir, d, mf.fog_b, mf.fog_c, mf.fog_d), 0.0);
}
if (abs(span.y - mf.fog_d.x) > 0.01) {
// Not the scene fog (an interior WMO pair): classic.
return classic;
}
var rgb = fog_rgb;
if (scene_colour) {
rgb = fog_modern_colour(fog_rgb, dir, d, mf.fog_b, mf.fog_c, mf.fog_d);
}
return vec4<f32>(rgb, fog_modern_survival(span, d, world_pos.y, mf.fog_a));
}
return classic;
}
@@ -14,7 +14,7 @@ struct MonkeyFrame {
fog_a: vec4<f32>, // height_fog_density, height_fog_height (world Y, yd), height_fog_falloff, curve_blend
fog_b: vec4<f32>, // sun_fog_rgb (gamma 0..1), sun_fog_strength
fog_c: vec4<f32>, // end_fog_rgb (gamma 0..1), end_fog_distance (yd)
fog_d: vec4<f32>, // fog_model (0 classic, 1 modern), sun_fog_angle (cos), 0, 0
fog_d: vec4<f32>, // 0 classic or scene fog end for Modern, sun_fog_angle, sun direction octahedral xy
wind_a: vec4<f32>, // dir_x, dir_y (unit, world XZ: .x = world x, .y = world z), speed (yd/s), gust 0..1
wind_b: vec4<f32>, // time_s, sway_strength, grass_strength, tree_strength
wet_a: vec4<f32>, // rain_rate 0..1, wetness 0..1, ripple_time_s, snow 0..1
+2
View File
@@ -132,6 +132,8 @@ pub use ground_effects::{
};
mod light;
pub use light::{Atmosphere, LightCatalog, Submersion, ZERO_KEY_COLOR, ZERO_KEY_SCALAR};
// MONKEY (fog): `LightFogBand.dbc`.
pub use light::{FogBand, FogBandCatalog, FOG_BANDS_PER_PARAM};
mod loading_screen;
pub use loading_screen::{load_loading_screens, LoadingScreenCatalog};
mod liquid;
+63
View File
@@ -11,9 +11,12 @@ use crate::dbc::{f32_at, parse, str_at, u32_at};
use crate::Chain;
mod atmosphere;
// MONKEY (fog): `LightFogBand.dbc`, the modern fog fields.
mod fog_band;
mod tables;
pub use atmosphere::Atmosphere;
pub use fog_band::{FogBand, FogBandCatalog, FOG_BANDS_PER_PARAM};
use atmosphere::{
FB_CLOUD_DENSITY, FB_FOG_END, FB_FOG_START_MULT, IB_AMBIENT, IB_CLOUD_GBASE, IB_CLOUD_SLOPE,
IB_CLOUD_SUN, IB_DIFFUSE, IB_FOG_COLOR, IB_OCEAN_DEEP, IB_OCEAN_SHALLOW, IB_RIVER_DEEP,
@@ -509,6 +512,66 @@ impl LightCatalog {
acc
}
/// MONKEY (fog): the `LightParams` ids [`Self::sample_blended`] folds, in its order, each with its
/// lerp weight (the base first at 1.0). A side table keyed by `LightParams` (`LightFogBand.dbc`)
/// blends through this with the same law. Magma and slime give their fixed row alone.
pub fn blend_chain(
&self,
map: u32,
pos: [f32; 3],
stormy: bool,
submersion: Submersion,
ghost: bool,
) -> Vec<(u32, f32)> {
if let Some(p) = submersion.fixed_param() {
if self.has_param(p) {
return vec![(p, 1.0)];
}
}
let slot = weather_slot(ghost, stormy, submersion.is_water());
let param_of = |l: &Light| match l.params[slot] {
0 => l.params[SLOT_CLEAR],
p => p,
};
let mut chain = Vec::new();
let map_has_no_light = !self.lights.iter().any(|l| l.map == map);
if let Some(base) = self
.lights
.iter()
.find(|l| l.map == map && l.global)
.or_else(|| {
map_has_no_light
.then(|| self.lights.iter().find(|l| l.id == FALLBACK_LIGHT_ID))
.flatten()
})
{
let p = param_of(base);
if p >= 1 {
chain.push((p, 1.0));
}
}
let mut locals: Vec<(f32, &Light)> = self
.lights
.iter()
.filter(|l| l.map == map && !l.global)
.filter_map(|l| {
let d = (0..3)
.map(|i| (l.pos[i] - pos[i]).powi(2))
.sum::<f32>()
.sqrt();
(d <= l.falloff_end).then_some((d, l))
})
.collect();
locals.sort_by(|a, b| b.0.total_cmp(&a.0));
for (dist, l) in locals {
let p = param_of(l);
if p >= 1 {
chain.push((p, blend_alpha(dist, l.falloff_start, l.falloff_end)));
}
}
chain
}
/// Debug: the spheres near `pos`, nearest first (the blend applies them farthest first), then
/// the blended result against the `pick_light` sample and the water swatch endpoints.
pub fn debug_blend(&self, map: u32, pos: [f32; 3], time: u32) {
@@ -0,0 +1,201 @@
//! MONKEY (fog): `LightFogBand.dbc`, the modern fog fields 1.12 has no column for.
//!
//! The file is ours (DESIGN-Benilla-Graphics §2a). It mirrors `LightFloatBand.dbc` exactly: the
//! same 34-field, 136-byte band record (`ID, num, time[16], value[16]`, times in half-minutes on
//! the 2880 day), interpolated the same way across midnight. `LightParams` id `P` keys
//! [`FOG_BANDS_PER_PARAM`] rows, band `b` at id `(P − 1)·13 + b + 1`, the `LightFloatBand`
//! indexing with its own stride, so the stock `paramID·6 + k` tables stay untouched.
//!
//! | band | field | unit |
//! |---|---|---|
//! | 0 | height-fog density | extra extinction below the plane, × the distance fog's (0 = off) |
//! | 1 | height-fog height | WoW world Z, yards |
//! | 2 | height-fog falloff | 1/yd: how fast the extra density fades above the plane |
//! | 3 | curve blend | 0 = exponential (modern), 1 = the 1.12 linear ramp |
//! | 4-6 | sun-fog colour r, g, b | gamma 0..1 |
//! | 7 | sun-fog strength | 0..1 |
//! | 8 | sun-fog angle | cosine threshold of the lobe around the sun |
//! | 9-11 | end-fog colour r, g, b | gamma 0..1 |
//! | 12 | end-fog distance | yards (0 = the scene fog end) |
//!
//! A missing file, or a `LightParams` id without rows, is not an error: the caller falls back to
//! the colours it derives from the 1.12 bands.
use std::collections::HashMap;
use benilla_dbc::FieldType;
use super::tables::{band_schema, load_float_bands, sample_float, Band};
use crate::Chain;
const FOG_BAND: &str = "DBFilesClient\\LightFogBand.dbc";
/// Rows per `LightParams` id.
pub const FOG_BANDS_PER_PARAM: u32 = 13;
/// One `LightParams` id's fog fields at one time of day (see the module table).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FogBand {
pub height_density: f32,
/// WoW world Z, yards.
pub height: f32,
pub height_falloff: f32,
pub curve_blend: f32,
pub sun_rgb: [f32; 3],
pub sun_strength: f32,
pub sun_angle: f32,
pub end_rgb: [f32; 3],
/// Yards; 0 = the scene fog end.
pub end_distance: f32,
}
impl FogBand {
/// Componentwise `self + (o − self)·t`, the area and storm blend.
pub fn lerp(&self, o: &FogBand, t: f32) -> FogBand {
let l = |a: f32, b: f32| a + (b - a) * t;
let l3 = |a: [f32; 3], b: [f32; 3]| [l(a[0], b[0]), l(a[1], b[1]), l(a[2], b[2])];
FogBand {
height_density: l(self.height_density, o.height_density),
height: l(self.height, o.height),
height_falloff: l(self.height_falloff, o.height_falloff),
curve_blend: l(self.curve_blend, o.curve_blend),
sun_rgb: l3(self.sun_rgb, o.sun_rgb),
sun_strength: l(self.sun_strength, o.sun_strength),
sun_angle: l(self.sun_angle, o.sun_angle),
end_rgb: l3(self.end_rgb, o.end_rgb),
end_distance: l(self.end_distance, o.end_distance),
}
}
}
/// The parsed `LightFogBand.dbc`; empty when the file is absent.
#[derive(Default)]
pub struct FogBandCatalog {
bands: HashMap<u32, Band<f32>>,
}
impl FogBandCatalog {
/// Read `LightFogBand.dbc` off the chain. Absent or unreadable → an empty catalog (every
/// lookup `None`), which is the 1.12 data set.
pub fn load(chain: &mut Chain) -> Self {
let bands = load_float_bands(
chain,
FOG_BAND,
band_schema("LightFogBand", FieldType::Float32),
)
.unwrap_or_default();
FogBandCatalog { bands }
}
/// Whether the file carried any row.
pub fn is_empty(&self) -> bool {
self.bands.is_empty()
}
/// `LightParams` id `p` at `time` (half-minutes), or `None` when `p` has no rows. A single
/// missing band of a present id reads 0.
pub fn sample(&self, p: u32, time: u32) -> Option<FogBand> {
if p < 1 {
return None;
}
let base = (p - 1) * FOG_BANDS_PER_PARAM + 1;
let rows: Vec<Option<f32>> = (0..FOG_BANDS_PER_PARAM)
.map(|b| self.bands.get(&(base + b)).and_then(|band| sample_float(band, time)))
.collect();
if rows.iter().all(Option::is_none) {
return None;
}
let v = |b: usize| rows[b].unwrap_or(0.0);
Some(FogBand {
height_density: v(0),
height: v(1),
height_falloff: v(2),
curve_blend: v(3),
sun_rgb: [v(4), v(5), v(6)],
sun_strength: v(7),
sun_angle: v(8),
end_rgb: [v(9), v(10), v(11)],
end_distance: v(12),
})
}
/// The area/storm blend of [`super::LightCatalog::blend_chain`]: each id's sample, or
/// `fallback` for an id without rows, lerped in chain order. `None` when no id in the chain has
/// rows, so the caller keeps its own derivation untouched.
pub fn sample_chain(
&self,
chain: &[(u32, f32)],
time: u32,
fallback: &FogBand,
) -> Option<FogBand> {
let mut any = false;
let mut acc = *fallback;
for &(p, w) in chain {
let v = match self.sample(p, time) {
Some(v) => {
any = true;
v
}
None => *fallback,
};
acc = acc.lerp(&v, w);
}
any.then_some(acc)
}
/// Test/tool constructor: a catalog from `(row id, [(time, value)])` bands.
pub fn from_rows(rows: &[(u32, &[(u32, f32)])]) -> Self {
let mut bands = HashMap::new();
for (id, keys) in rows {
bands.insert(*id, Band::from_keys(keys));
}
FogBandCatalog { bands }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn neutral() -> FogBand {
FogBand {
height_density: 0.0,
height: 0.0,
height_falloff: 0.0,
curve_blend: 0.0,
sun_rgb: [0.0; 3],
sun_strength: 0.0,
sun_angle: 1.0,
end_rgb: [0.0; 3],
end_distance: 0.0,
}
}
#[test]
fn empty_catalog_is_absent() {
let c = FogBandCatalog::default();
assert!(c.is_empty());
assert_eq!(c.sample(12, 1440), None);
assert_eq!(c.sample_chain(&[(12, 1.0)], 1440, &neutral()), None);
}
#[test]
fn rows_land_at_the_param_stride_and_interpolate() {
// Param 12, band 7 (sun strength): 0 at 06:00, 1 at 18:00.
let id = 11 * FOG_BANDS_PER_PARAM + 7 + 1;
let c = FogBandCatalog::from_rows(&[(id, &[(720, 0.0), (2160, 1.0)])]);
let noon = c.sample(12, 1440).unwrap();
assert!((noon.sun_strength - 0.5).abs() < 1e-6);
assert_eq!(noon.height_density, 0.0);
assert_eq!(c.sample(13, 1440), None);
}
#[test]
fn chain_blends_rows_against_the_fallback() {
let id = 11 * FOG_BANDS_PER_PARAM + 7 + 1;
let c = FogBandCatalog::from_rows(&[(id, &[(0, 1.0)])]);
// Base 13 (no rows → fallback strength 0), then param 12 at weight 0.25.
let out = c.sample_chain(&[(13, 1.0), (12, 0.25)], 100, &neutral()).unwrap();
assert!((out.sun_strength - 0.25).abs() < 1e-6);
}
}
@@ -18,6 +18,16 @@ pub(super) struct Band<T> {
values: Vec<T>,
}
// MONKEY (fog): a band from `(time, value)` keys, for `fog_band`'s tests and tools.
impl<T: Copy> Band<T> {
pub(super) fn from_keys(keys: &[(u32, T)]) -> Self {
Band {
times: keys.iter().map(|k| k.0).collect(),
values: keys.iter().map(|k| k.1).collect(),
}
}
}
/// The shared band record: `ID, num, time[16], value[16]`, 34 fields, 136 bytes.
pub(super) fn band_schema(name: &str, value: FieldType) -> Schema {
let mut s = Schema::new(name);
@@ -0,0 +1,310 @@
//! MONKEY (fog): the Modern fog model's CPU half — fills the FOG rows of [`MonkeyFrame`] every
//! frame. The law itself lives in `benilla-assets/src/shaders/fog_hook.wgsl`.
//!
//! Setting: cvar `fogModel` (0 Classic = the 1.12 linear fog, byte-identical; 1 Modern), bridged by
//! `benilla-app/src/monkey_gfx.rs` into [`FogModelSetting`]; `WOW_FOGMODEL=0|1` overrides it for a
//! session (captures).
//!
//! Modern, per frame:
//! - **Distances** stay the zone's: the shader's exponential curve is fitted to the 1.12 start/end
//! pair it already receives. Only past the reference's farclip ceiling does the end grow with the
//! view distance ([`modern_fog_end`], called from `resolve.rs`), so a larger `farclip` shows more.
//! - **Colours** without `LightFogBand.dbc` rows are derived from the 1.12 bands
//! ([`derived_band`]): the sun-fog lobe leans the fog toward the sun colour (IntBand 9) around the
//! sun and fades out at night; the far fog shifts toward the sky's lowest ring (1.8°) so the
//! fogged world, the WDL hull and the sky dome's horizon meet in one colour. Height fog is off.
//! - With `LightFogBand.dbc` rows, those override the derivation per `LightParams`, blended over
//! the same area-light chain as the stock bands.
//!
//! Packed rows (see `monkey_frame.rs`): `fog_a` height density / height (Bevy Y) / falloff / curve
//! blend; `fog_b` sun rgb / strength × day; `fog_c` end rgb / end distance; `fog_d` the scene fog end
//! (> 0 = Modern; the shader applies the model only to spans ending there, so an interior WMO fog
//! stays classic) / sun-fog cosine / the sun direction, octahedral-encoded.
use bevy::prelude::*;
use benilla_assets::{LockRecover, WorldAssets};
use benilla_formats::{FogBand, FogBandCatalog};
use super::{FogModel, LightSampler, MonkeyFrame, WowLighting};
use crate::terrain_stream::SPAWN_XY;
use crate::view::WorldCamera;
use crate::world_map::CurrentMap;
use benilla_assets::coords::bevy_to_wow;
/// The live fog-model choice (cvar `fogModel`). Default Classic; `WOW_FOGMODEL` overrides.
#[derive(Resource, Clone, Copy, Debug, PartialEq, Eq)]
pub struct FogModelSetting(pub FogModel);
impl Default for FogModelSetting {
fn default() -> Self {
FogModelSetting(env_override().unwrap_or(FogModel::Classic))
}
}
/// `WOW_FOGMODEL=0|1`, read once.
fn env_override() -> Option<FogModel> {
static ENV: std::sync::OnceLock<Option<FogModel>> = std::sync::OnceLock::new();
*ENV.get_or_init(|| {
std::env::var("WOW_FOGMODEL").ok().and_then(|v| match v.trim() {
"0" => Some(FogModel::Classic),
"1" => Some(FogModel::Modern),
_ => None,
})
})
}
impl FogModelSetting {
/// Apply a cvar write; the env override, when set, wins for the session.
pub fn set_from_cvar(&mut self, v: f32) {
let want = env_override().unwrap_or(if v >= 0.5 {
FogModel::Modern
} else {
FogModel::Classic
});
if self.0 != want {
self.0 = want;
}
}
pub fn modern(&self) -> bool {
self.0 == FogModel::Modern
}
}
/// `LightFogBand.dbc`, loaded once; empty without the file.
#[derive(Resource, Default)]
pub struct FogBandTable(pub FogBandCatalog);
/// The reference client's farclip ceiling (`FARCLIP_RANGE` end). Up to it Modern keeps the 1.12
/// fog pair exactly; beyond it the fog end scales with the view distance.
pub const FOG_REF_FARCLIP: f32 = 777.0;
/// The Modern scene fog pair: the zone's end, stretched by `farclip / 777` once the view distance
/// passes the reference ceiling, and never past `farclip` (the wall). Classic is
/// `(frac·min(end, farclip), min(end, farclip))`; at `farclip ≤ 777` the two are identical.
pub fn modern_fog_end(fog_end_raw: f32, fog_start_frac: f32, farclip: f32) -> (f32, f32) {
let stretch = (farclip / FOG_REF_FARCLIP).max(1.0);
let end = (fog_end_raw * stretch).min(farclip);
(fog_start_frac * end, end)
}
/// Sun-fog lobe defaults (no `LightFogBand` row): strength and the cosine the lobe starts at.
pub const DERIVED_SUN_FOG_STRENGTH: f32 = 0.25;
pub const DERIVED_SUN_FOG_ANGLE: f32 = 0.4;
/// How far the sun-fog colour leans from the fog colour toward the sun colour (IntBand 9).
const DERIVED_SUN_FOG_LEAN: f32 = 0.4;
/// How far the end-fog colour leans from the fog colour toward the 1.8° sky ring.
const DERIVED_END_FOG_LEAN: f32 = 0.25;
fn mix3(a: [f32; 3], b: [f32; 3], t: f32) -> [f32; 3] {
[
a[0] + (b[0] - a[0]) * t,
a[1] + (b[1] - a[1]) * t,
a[2] + (b[2] - a[2]) * t,
]
}
/// The Modern fields from 1.12 data alone: no height fog, the exponential curve, a sun-fog lobe in
/// the sun's colour and an end colour leaning to the sky's lowest ring, ending at the scene fog end.
pub fn derived_band(l: &WowLighting) -> FogBand {
FogBand {
height_density: 0.0,
height: 0.0,
height_falloff: 0.0,
curve_blend: 0.0,
sun_rgb: mix3(l.fog_color, l.spec, DERIVED_SUN_FOG_LEAN),
sun_strength: DERIVED_SUN_FOG_STRENGTH,
sun_angle: DERIVED_SUN_FOG_ANGLE,
end_rgb: mix3(l.fog_color, l.sky[4], DERIVED_END_FOG_LEAN),
end_distance: 0.0,
}
}
/// The sun-fog day factor from the visible sun's height (`sin(elevation)`): full from ~3° up,
/// fading through the horizon to 0 by ~6° below it (the modern client's SunAngleBlend ramp).
pub fn sun_fog_day(sun_y: f32) -> f32 {
let t = ((sun_y + 0.1) / 0.15).clamp(0.0, 1.0);
t * t * (3.0 - 2.0 * t)
}
/// Octahedral encoding of a unit vector into `[-1, 1]²` (the shader's `oct_decode`).
pub fn oct_encode(v: Vec3) -> [f32; 2] {
let v = v.normalize_or(Vec3::Y);
let n = v / (v.x.abs() + v.y.abs() + v.z.abs());
if n.y >= 0.0 {
[n.x, n.z]
} else {
let sx = if n.x >= 0.0 { 1.0 } else { -1.0 };
let sz = if n.z >= 0.0 { 1.0 } else { -1.0 };
[(1.0 - n.z.abs()) * sx, (1.0 - n.x.abs()) * sz]
}
}
/// Write the FOG rows of `frame` for `band` under the resolved light. Classic zeroes them.
pub fn fill_frame(frame: &mut MonkeyFrame, model: FogModel, l: &WowLighting, band: &FogBand) {
if model == FogModel::Classic {
frame.fog_model = FogModel::Classic;
frame.fog_scene_end = 0.0;
frame.height_fog_density = 0.0;
frame.height_fog_height = 0.0;
frame.height_fog_falloff = 0.0;
frame.fog_curve_blend = 0.0;
frame.sun_fog_rgb = [0.0; 3];
frame.sun_fog_strength = 0.0;
frame.sun_fog_angle = 0.0;
frame.sun_fog_dir = [0.0; 2];
frame.end_fog_rgb = [0.0; 3];
frame.end_fog_distance = 0.0;
return;
}
let sun = l.celestial_dir;
frame.fog_model = FogModel::Modern;
frame.fog_scene_end = l.fog_end;
frame.height_fog_density = band.height_density.max(0.0);
// WoW Z is Bevy Y.
frame.height_fog_height = band.height;
frame.height_fog_falloff = band.height_falloff.max(0.0);
frame.fog_curve_blend = band.curve_blend.clamp(0.0, 1.0);
frame.sun_fog_rgb = band.sun_rgb;
frame.sun_fog_strength = band.sun_strength.clamp(0.0, 1.0) * sun_fog_day(sun.y);
frame.sun_fog_angle = band.sun_angle.clamp(-1.0, 0.999);
frame.sun_fog_dir = oct_encode(sun);
frame.end_fog_rgb = band.end_rgb;
frame.end_fog_distance = if band.end_distance > 0.0 {
band.end_distance
} else {
l.fog_end
};
}
/// Startup: read `LightFogBand.dbc` (absent → empty).
pub(super) fn load_fog_bands(mut commands: Commands, world_assets: Option<ResMut<WorldAssets>>) {
let Some(world_assets) = world_assets else {
return;
};
let mut chain = world_assets.chain.lock_recover();
let cat = FogBandCatalog::load(&mut chain);
if !cat.is_empty() {
info!("LightFogBand.dbc loaded: Modern fog uses its authored fields");
}
commands.insert_resource(FogBandTable(cat));
}
/// Per frame: the Modern fog fields into [`MonkeyFrame`] (after the light resolve).
#[allow(clippy::too_many_arguments)]
pub(super) fn update_fog_model(
setting: Res<FogModelSetting>,
lighting: Res<WowLighting>,
table: Option<Res<FogBandTable>>,
sampler: Option<Res<LightSampler>>,
clock: Res<super::GameClock>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
current_map: Option<Res<CurrentMap>>,
eye_liquid: crate::liquid::EyeLiquid,
viewer: Res<crate::view::Viewer>,
weather: Option<Res<crate::weather::WeatherState>>,
mut frame: ResMut<MonkeyFrame>,
) {
let derived = derived_band(&lighting);
let band = match (&table, &sampler, setting.modern()) {
(Some(t), Some(s), true) if !t.0.is_empty() => {
let pos = match cam.single() {
Ok(t) => bevy_to_wow(t.translation()),
Err(_) => [SPAWN_XY.0, SPAWN_XY.1, 83.5],
};
let map = current_map.as_ref().map_or(0, |m| m.0);
let sub = eye_liquid.submersion();
let time = clock.minute * 2;
let storm = weather
.as_ref()
.map_or(0.0, |w| crate::weather::storm_blend(w.sky_density));
let chain = |stormy| s.0.blend_chain(map, pos, stormy, sub, viewer.ghost);
let clear = t.0.sample_chain(&chain(false), time, &derived);
let stormy = (storm > 0.0)
.then(|| t.0.sample_chain(&chain(true), time, &derived))
.flatten();
match (clear, stormy) {
(None, None) => derived,
(c, s) => c.unwrap_or(derived).lerp(&s.unwrap_or(derived), storm),
}
}
_ => derived,
};
let mut next = *frame;
fill_frame(&mut next, setting.0, &lighting, &band);
if *frame != next {
*frame = next;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn modern_end_matches_classic_up_to_the_reference_farclip() {
for far in [177.0, 350.0, 500.0, 777.0] {
for end in [120.0, 444.0, 900.0] {
let classic_end: f32 = f32::min(end, far);
let (s, e) = modern_fog_end(end, 0.25, far);
assert_eq!(e, classic_end);
assert_eq!(s, 0.25 * classic_end);
}
}
}
#[test]
fn modern_end_grows_past_the_reference_farclip() {
let (_, e) = modern_fog_end(444.0, 0.25, 1554.0);
assert!((e - 888.0).abs() < 1e-3);
// Never past the wall.
let (_, e) = modern_fog_end(900.0, 0.25, 1000.0);
assert_eq!(e, 1000.0);
}
#[test]
fn classic_leaves_the_fog_rows_zero() {
let mut f = MonkeyFrame::default();
let l = WowLighting::default();
fill_frame(&mut f, FogModel::Classic, &l, &derived_band(&l));
assert_eq!(f, MonkeyFrame::default());
assert!(f.pack(0.0, 0.0)[..4].iter().flatten().all(|v| *v == 0.0));
}
#[test]
fn modern_marks_the_scene_end_and_fades_the_sun_at_night() {
let mut l = WowLighting::default();
l.fog_end = 444.0;
l.celestial_dir = Vec3::new(0.0, 0.8, 0.6);
let mut f = MonkeyFrame::default();
fill_frame(&mut f, FogModel::Modern, &l, &derived_band(&l));
assert_eq!(f.fog_scene_end, 444.0);
assert_eq!(f.end_fog_distance, 444.0);
assert!((f.sun_fog_strength - DERIVED_SUN_FOG_STRENGTH).abs() < 1e-6);
l.celestial_dir = Vec3::new(0.0, -0.5, 0.86);
fill_frame(&mut f, FogModel::Modern, &l, &derived_band(&l));
assert_eq!(f.sun_fog_strength, 0.0);
}
#[test]
fn oct_encoding_round_trips() {
fn decode(e: [f32; 2]) -> Vec3 {
let mut v = Vec3::new(e[0], 1.0 - e[0].abs() - e[1].abs(), e[1]);
let t = (-v.y).max(0.0);
v.x += if v.x >= 0.0 { -t } else { t };
v.z += if v.z >= 0.0 { -t } else { t };
v.normalize()
}
for d in [
Vec3::Y,
-Vec3::Y,
Vec3::new(0.3, 0.2, -0.9),
Vec3::new(-0.7, -0.1, 0.7),
Vec3::new(0.1, -0.9, -0.4),
] {
let d = d.normalize();
assert!(decode(oct_encode(d)).dot(d) > 0.9999, "{d:?}");
}
}
}
+17
View File
@@ -13,6 +13,8 @@ mod flicker; // MONKEY (flame flicker): the per-light fire wobble folded in at p
mod global_light; // the one shared global-light storage buffer (replaces the per-material push)
mod monkey_frame; // MONKEY (p0 MonkeyFrame): the programme's per-frame block after the point table
pub use monkey_frame::{FogModel, MonkeyFrame, MAX_BENDERS, MONKEY_FRAME_ROWS};
pub mod fog_model; // MONKEY (fog): the Modern fog model's CPU half (MonkeyFrame fog rows)
pub use fog_model::FogModelSetting;
mod lava_light; // MONKEY (lava light): magma surface fixtures and their independent gain
pub use lava_light::{LavaLight, LavaLightGain};
mod prop_probes; // the per-instance interior-prop SH probe table (slot ↔ MeshTag payload)
@@ -322,6 +324,16 @@ impl Plugin for LightingPlugin {
// This frame's submersion verdict, which the sky-pass suppression also reads.
.after(crate::liquid::SubmersionVerdict),
);
// MONKEY (fog): the fog-model setting, LightFogBand.dbc and the per-frame fog rows.
app.init_resource::<fog_model::FogModelSetting>()
.init_resource::<fog_model::FogBandTable>()
.add_systems(Startup, fog_model::load_fog_bands.after(AssetSet::Open))
.add_systems(
Update,
fog_model::update_fog_model
.after(update_time_lighting)
.in_set(LightingResolveSet),
);
// The shared light buffer, packed after the resolve and uploaded in the render world.
global_light::register(app);
// MONKEY (daylight fixtures): the per-frame re-aim, ordered before the packer's own set.
@@ -399,6 +411,11 @@ mod ordering_tests {
"the celestial follows: PostUpdate, BillboardPlace",
),
("weather/precip/mod.rs", "push_precip: PostUpdate"),
// MONKEY (fog)
(
"lighting/fog_model.rs",
"update_fog_model: in the resolve set, .after(update_time_lighting)",
),
// MONKEY (daylight fixtures / portal bleed): both systems are PostUpdate,
// chained before `global_light::classify_light_lanes`.
(
@@ -17,7 +17,7 @@
//! | 0 `fog_a` | height_fog_density, height_fog_height, height_fog_falloff | curve_blend |
//! | 1 `fog_b` | sun_fog_rgb | sun_fog_strength |
//! | 2 `fog_c` | end_fog_rgb | end_fog_distance |
//! | 3 `fog_d` | fog_model (0 classic, 1 modern), sun_fog_angle, 0 | 0 |
//! | 3 `fog_d` | fog_model (0 classic; Modern = the scene fog end, yd, ≥ 1), sun_fog_angle, sun dir oct.x | sun dir oct.y |
//! | 4 `wind_a` | dir_x, dir_y, speed | gust |
//! | 5 `wind_b` | time_s, sway_strength, grass_strength | tree_strength |
//! | 6 `wet_a` | rain_rate, wetness, ripple_time_s | snow |
@@ -64,6 +64,11 @@ pub struct MonkeyFrame {
pub sun_fog_strength: f32,
/// Cosine threshold of the sun-fog lobe.
pub sun_fog_angle: f32,
/// MONKEY (fog): the sun direction (Bevy space), octahedral-encoded (`fog_model::oct_encode`).
pub sun_fog_dir: [f32; 2],
/// MONKEY (fog): the scene fog end (yd) the Modern law applies to; packed as `fog_d.x`, so an
/// interior WMO span (a different end) stays classic.
pub fog_scene_end: f32,
/// Gamma 0..1.
pub end_fog_rgb: [f32; 3],
pub end_fog_distance: f32,
@@ -94,9 +99,10 @@ impl MonkeyFrame {
/// The 16 packed rows. `time_of_day` (0..1) and `night` (0..1) are the packer's own inputs.
pub fn pack(&self, time_of_day: f32, night: f32) -> [[f32; 4]; MONKEY_FRAME_ROWS] {
let mut rows = [[0.0f32; 4]; MONKEY_FRAME_ROWS];
// MONKEY (fog): Modern packs the scene fog end (never below 1) as its flag.
let model = match self.fog_model {
FogModel::Classic => 0.0,
FogModel::Modern => 1.0,
FogModel::Modern => self.fog_scene_end.max(1.0),
};
rows[0] = [
self.height_fog_density,
@@ -108,7 +114,7 @@ impl MonkeyFrame {
rows[1] = [s[0], s[1], s[2], self.sun_fog_strength];
let e = self.end_fog_rgb;
rows[2] = [e[0], e[1], e[2], self.end_fog_distance];
rows[3] = [model, self.sun_fog_angle, 0.0, 0.0];
rows[3] = [model, self.sun_fog_angle, self.sun_fog_dir[0], self.sun_fog_dir[1]];
rows[4] = [self.wind_dir[0], self.wind_dir[1], self.wind_speed, self.wind_gust];
rows[5] = [self.wind_time_s, self.sway_strength, self.grass_strength, self.tree_strength];
rows[6] = [self.rain_rate, self.wetness, self.ripple_time_s, self.snow];
@@ -144,6 +150,8 @@ mod tests {
end_fog_rgb: [9.0, 10.0, 11.0],
end_fog_distance: 12.0,
sun_fog_angle: 13.0,
sun_fog_dir: [0.5, -0.5],
fog_scene_end: 444.0,
wind_dir: [14.0, 15.0],
wind_speed: 16.0,
wind_gust: 17.0,
@@ -162,7 +170,7 @@ mod tests {
assert_eq!(r[0], [1.0, 2.0, 3.0, 4.0]);
assert_eq!(r[1], [5.0, 6.0, 7.0, 8.0]);
assert_eq!(r[2], [9.0, 10.0, 11.0, 12.0]);
assert_eq!(r[3], [1.0, 13.0, 0.0, 0.0]);
assert_eq!(r[3], [444.0, 13.0, 0.5, -0.5]);
assert_eq!(r[4], [14.0, 15.0, 16.0, 17.0]);
assert_eq!(r[5], [18.0, 19.0, 20.0, 21.0]);
assert_eq!(r[6], [22.0, 23.0, 24.0, 25.0]);
+9 -2
View File
@@ -177,7 +177,9 @@ pub(super) fn update_time_lighting(
eye_liquid: crate::liquid::EyeLiquid,
viewer: Res<crate::view::Viewer>,
weather: Option<Res<crate::weather::WeatherState>>,
view: Res<crate::view::ViewDistance>,
// MONKEY (fog): paired with the fog-model setting (Modern decouples the fog end from farclip
// past the reference ceiling); a tuple keeps the system under Bevy's 16-parameter limit.
(view, fog_setting): (Res<crate::view::ViewDistance>, Res<super::FogModelSetting>),
time: Res<Time>,
wmo_fog: Res<crate::wmo_portal::CameraWmoFog>,
mut wmo_ramp: ResMut<WmoCrossfade>,
@@ -250,7 +252,12 @@ pub(super) fn update_time_lighting(
// Elwynn commits 125/500 clear and −139/278 at full storm (`0x6cee30`): the negative start is
// the near veil, and the short storm end whites out the middle distance.
let (fog_start, fog_end) = scene_fog(atmo.fog_end, atmo.fog_start_frac, view.farclip);
let (fog_start, fog_end) = if fog_setting.modern() {
// MONKEY (fog): identical up to farclip 777, then the end grows with the view distance.
super::fog_model::modern_fog_end(atmo.fog_end, atmo.fog_start_frac, view.farclip)
} else {
scene_fog(atmo.fog_end, atmo.fog_start_frac, view.farclip)
};
// The interior crossfade is its own triple; the scene fog stays untouched. Submerged, the
// underwater param owns the fog and the ramp is bypassed, as the reference snaps it for magma
// and slime inside an MFOG interior (`0x6cef6f`); the reference's MFOG underwater block
+28 -18
View File
@@ -10,6 +10,8 @@
#ifdef SKY_FX
#import benilla_world::sky_fx
#endif
// MONKEY (fog): the Modern fog colour at the horizon.
#import benilla::fog_hook
struct SkyColors {
sky0: vec4<f32>, // zenith (90°)
@@ -26,6 +28,10 @@ struct SkyColors {
sun: vec4<f32>,
// MONKEY (sky): rgb = glow colour (gamma), w unused.
glow: vec4<f32>,
// MONKEY (fog): MonkeyFrame rows B-D (all zero = Classic).
mf_fog_b: vec4<f32>,
mf_fog_c: vec4<f32>,
mf_fog_d: vec4<f32>,
};
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var<uniform> sky: SkyColors;
@@ -78,6 +84,22 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
s4 = mix(warp_one(sky.sky4.rgb, g0, warp_s), warp_one(sky.sky4.rgb, g1, warp_s), f);
}
// Elevation gradient, linear between rings like the reference's Gouraud-shaded dome.
var col: vec3<f32>;
if (elev <= 0.0) {
col = sky.fog.rgb; // horizon and below = fog colour (row 7), unwarped
} else if (elev < 1.8) {
col = mix(sky.fog.rgb, s4, elev / 1.8);
} else if (elev < 3.7) {
col = mix(s4, s3, (elev - 1.8) / (3.7 - 1.8));
} else if (elev < 9.8) {
col = mix(s3, s2c, (elev - 3.7) / (9.8 - 3.7));
} else if (elev < 16.8) {
col = mix(s2c, s1, (elev - 9.8) / (16.8 - 9.8));
} else {
col = mix(s1, sky.sky0.rgb, (elev - 16.8) / (90.0 - 16.8));
}
// MONKEY (sky): Enhanced/High. The same stops through a monotone cubic in linear light, then
// the sun glow and the night sky added in linear light, back to gamma, dithered.
#ifdef SKY_FX
@@ -111,27 +133,15 @@ fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
lin += (stars * 0.85 + milky) * night;
}
let out = sky_fx::linear_to_srgb(lin) + vec3<f32>(sky_fx::dither_tri(in.position.xy));
return vec4<f32>(clamp(out, vec3<f32>(0.0), vec3<f32>(1.0)), 1.0);
col = clamp(out, vec3<f32>(0.0), vec3<f32>(1.0));
}
#endif
// Elevation gradient, linear between rings like the reference's Gouraud-shaded dome.
var col: vec3<f32>;
if (elev <= 0.0) {
col = sky.fog.rgb; // horizon and below = fog colour (row 7), unwarped
} else if (elev < 1.8) {
col = mix(sky.fog.rgb, s4, elev / 1.8);
} else if (elev < 3.7) {
col = mix(s4, s3, (elev - 1.8) / (3.7 - 1.8));
} else if (elev < 9.8) {
col = mix(s3, s2c, (elev - 3.7) / (9.8 - 3.7));
} else if (elev < 16.8) {
col = mix(s2c, s1, (elev - 9.8) / (16.8 - 9.8));
} else {
col = mix(s1, sky.sky0.rgb, (elev - 16.8) / (90.0 - 16.8)); // warped ring1 to the raw apex
}
// Raw gamma out: the reference draws the sky as raw DBC bytes, sRGB off (`0x6d4940`).
let rgb = col;
// MONKEY (fog): Modern eases the low band into the world's far fog colour after either the
// Classic or Enhanced/High gradient path; Classic returns `col` unchanged.
let rgb = fog_hook::fog_sky_horizon(
col, sky.fog.rgb, dir, sky.mf_fog_b, sky.mf_fog_c, sky.mf_fog_d,
);
return vec4<f32>(rgb, 1.0);
}
+26
View File
@@ -66,6 +66,13 @@ pub struct SkyExt {
/// MONKEY (sky): the glow colour, gamma (`rgb`).
#[uniform(100)]
pub(crate) glow: Vec4,
/// MONKEY (fog): MonkeyFrame fog rows B-D so the dome horizon uses the world's far colour.
#[uniform(100)]
pub(crate) mf_fog_b: Vec4,
#[uniform(100)]
pub(crate) mf_fog_c: Vec4,
#[uniform(100)]
pub(crate) mf_fog_d: Vec4,
}
/// MONKEY (sky): the pipeline key: Enhanced/High compile `SKY_FX` into `sky.wgsl`; Classic keeps
@@ -217,6 +224,10 @@ fn setup_sky(
fx: Vec4::ZERO,
sun: Vec4::Y,
glow: Vec4::ZERO,
// MONKEY (fog): zero rows leave Classic unchanged.
mf_fog_b: Vec4::ZERO,
mf_fog_c: Vec4::ZERO,
mf_fog_d: Vec4::ZERO,
},
});
commands.spawn((
@@ -283,6 +294,8 @@ fn update_sky_colors(
quality: Res<crate::sky_fx::SkyQuality>,
clock: Res<crate::sky_fx::SkyClock>,
clouds: Res<crate::clouds::CloudCoverage>,
// MONKEY (fog): the Modern fog rows for the shared horizon colour.
monkey: Res<crate::lighting::MonkeyFrame>,
dome: Query<&MeshMaterial3d<SkyMaterial>, With<Sky>>,
mut materials: ResMut<Assets<SkyMaterial>>,
) {
@@ -311,6 +324,13 @@ fn update_sky_colors(
);
// MONKEY (sky): the Enhanced/High inputs; all zero at Classic so the write gate stays quiet.
let (fx, sun_v, glow) = sky_fx_inputs(&light, *quality, clock.secs, &clouds);
// MONKEY (fog): rows B-D as packed for the light buffer; clock lanes are not read here.
let rows = monkey.pack(0.0, 0.0);
let mf_fog = [
Vec4::from_array(rows[1]),
Vec4::from_array(rows[2]),
Vec4::from_array(rows[3]),
];
benilla_assets::write_gated(
&mut materials,
&handle.0,
@@ -318,6 +338,9 @@ fn update_sky_colors(
m.extension.fx != fx
|| m.extension.sun != sun_v
|| m.extension.glow != glow
|| m.extension.mf_fog_b != mf_fog[0]
|| m.extension.mf_fog_c != mf_fog[1]
|| m.extension.mf_fog_d != mf_fog[2]
|| m.extension.sky0 != sky[0]
|| m.extension.sky1 != sky[1]
|| m.extension.sky2 != sky[2]
@@ -337,6 +360,9 @@ fn update_sky_colors(
m.extension.fx = fx;
m.extension.sun = sun_v;
m.extension.glow = glow;
m.extension.mf_fog_b = mf_fog[0];
m.extension.mf_fog_c = mf_fog[1];
m.extension.mf_fog_d = mf_fog[2];
},
);
}