diff --git a/LIGHTING.md b/LIGHTING.md
index c7de7b00..e230a2de 100644
--- a/LIGHTING.md
+++ b/LIGHTING.md
@@ -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 |
diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index e515d034..2d5ac9ea 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -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")
-
-
+
+
+
+ OptionsRow_OnLoad(self, "fogModel",
+ BENILLA_ADVGFX.fogModel,
+ "BENILLA_TOOLTIP_FOG_MODEL")
+
+
+
+
+
+
+
+
OptionsRow_OnLoad(self, "foliageWind",
BENILLA_ADVGFX.foliageWind,
diff --git a/crates/benilla-app/src/capture/scenarios.rs b/crates/benilla-app/src/capture/scenarios.rs
index 3438f5a1..6c6b66f7 100644
--- a/crates/benilla-app/src/capture/scenarios.rs
+++ b/crates/benilla-app/src/capture/scenarios.rs
@@ -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
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index 008532be..9c49e5f8 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -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::();
+ // MONKEY (fog)
+ app.init_resource::();
app.add_observer(crate::monkey_gfx::on_cvar);
},
|app| {
diff --git a/crates/benilla-app/src/monkey_gfx.rs b/crates/benilla-app/src/monkey_gfx.rs
index 630a9c07..26d0e4b4 100644
--- a/crates/benilla-app/src/monkey_gfx.rs
+++ b/crates/benilla-app/src/monkey_gfx.rs
@@ -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,
mut dither: ResMut,
mut foliage_wind: ResMut,
+ // MONKEY (fog)
+ mut fog_model: ResMut,
) {
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::()
.init_resource::()
+ .init_resource::()
.add_observer(on_cvar);
}
}
diff --git a/crates/benilla-app/src/volumetric_fog.rs b/crates/benilla-app/src/volumetric_fog.rs
index 7c59b9f2..aff59f9c 100644
--- a/crates/benilla-app/src/volumetric_fog.rs
+++ b/crates/benilla-app/src/volumetric_fog.rs
@@ -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,
weather: Res,
interior: Res,
+ // MONKEY (fog): the Modern fog rows (Option: absent in unit tests).
+ monkey: Option>,
mut cameras: Query<(Entity, &Camera, &mut Camera3d), With>,
suns: Query<(Entity, &DirectionalLight), With>,
) {
@@ -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;
#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 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, sun_strength: vec4, direction_steps: vec4 }
+struct Fog {
+ color_density: vec4, sun_strength: vec4, direction_steps: vec4,
+ // MONKEY (fog): MonkeyFrame fog1..fog3.
+ mf_fog1: vec4, mf_fog2: vec4, mf_fog3: vec4,
+}
@group(0) @binding(6) var fog: Fog;
// Integral of smoothstep(10, 30, distance): zero in the first ten yards,
@@ -420,9 +437,17 @@ fn fragment(@builtin(position) pixel: vec4) -> @location(0) vec4 {
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));
diff --git a/crates/benilla-assets/src/shaders/fog_hook.wgsl b/crates/benilla-assets/src/shaders/fog_hook.wgsl
index 6730fbd8..d521d45f 100644
--- a/crates/benilla-assets/src/shaders/fog_hook.wgsl
+++ b/crates/benilla-assets/src/shaders/fog_hook.wgsl
@@ -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 {
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) -> vec3 {
+ var v = vec3(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) -> 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,
+ dir: vec3,
+ d: f32,
+ sun_row: vec4,
+ end_row: vec4,
+ sel_row: vec4,
+) -> vec3 {
+ 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, d: f32, world_y: f32, height_row: vec4) -> 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,
+ horizon_rgb: vec3,
+ dir: vec3,
+ sun_row: vec4,
+ end_row: vec4,
+ sel_row: vec4,
+) -> vec3 {
+ 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 {
let classic = vec4(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(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(rgb, fog_modern_survival(span, d, world_pos.y, mf.fog_a));
}
return classic;
}
diff --git a/crates/benilla-assets/src/shaders/monkey_frame.wgsl b/crates/benilla-assets/src/shaders/monkey_frame.wgsl
index 4600d38e..234f442e 100644
--- a/crates/benilla-assets/src/shaders/monkey_frame.wgsl
+++ b/crates/benilla-assets/src/shaders/monkey_frame.wgsl
@@ -14,7 +14,7 @@ struct MonkeyFrame {
fog_a: vec4, // height_fog_density, height_fog_height (world Y, yd), height_fog_falloff, curve_blend
fog_b: vec4, // sun_fog_rgb (gamma 0..1), sun_fog_strength
fog_c: vec4, // end_fog_rgb (gamma 0..1), end_fog_distance (yd)
- fog_d: vec4, // fog_model (0 classic, 1 modern), sun_fog_angle (cos), 0, 0
+ fog_d: vec4, // 0 classic or scene fog end for Modern, sun_fog_angle, sun direction octahedral xy
wind_a: vec4, // dir_x, dir_y (unit, world XZ: .x = world x, .y = world z), speed (yd/s), gust 0..1
wind_b: vec4, // time_s, sway_strength, grass_strength, tree_strength
wet_a: vec4, // rain_rate 0..1, wetness 0..1, ripple_time_s, snow 0..1
diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs
index fb9d9216..db19e1e7 100644
--- a/crates/benilla-formats/src/lib.rs
+++ b/crates/benilla-formats/src/lib.rs
@@ -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;
diff --git a/crates/benilla-formats/src/light.rs b/crates/benilla-formats/src/light.rs
index 3edc847e..cb19d6de 100644
--- a/crates/benilla-formats/src/light.rs
+++ b/crates/benilla-formats/src/light.rs
@@ -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::()
+ .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) {
diff --git a/crates/benilla-formats/src/light/fog_band.rs b/crates/benilla-formats/src/light/fog_band.rs
new file mode 100644
index 00000000..56aea8f5
--- /dev/null
+++ b/crates/benilla-formats/src/light/fog_band.rs
@@ -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>,
+}
+
+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 {
+ if p < 1 {
+ return None;
+ }
+ let base = (p - 1) * FOG_BANDS_PER_PARAM + 1;
+ let rows: Vec