From fc99f2c858be168d4a6ff06311498359d0f27ac3 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Fri, 25 Sep 2026 01:33:52 +0100
Subject: [PATCH] MONKEY (p0): fog_hook.wgsl, one distance-fog law for every
receiver
`benilla::fog_hook` (fog_linear / fog_sample / apply_fog) replaces the seven
hand-rolled copies of the linear planar-eye-Z fog: terrain, wow_model,
static_gx, liquid, enhanced_water (factor form), wow_effect (scene fog; the
rain streak fade uses fog_linear) and wdl (the hull's own 0..1 yd pair).
Pure functions, values passed as arguments, same expressions in the same
order, so the classic result is bit-identical. `fog_sample` branches on
MonkeyFrame.fog_model (fog3.x) as the FOG lane's extension point, currently
returning the classic result. wow_effect gains a world_pos varying for it.
Co-Authored-By: Claude Opus 5.5
---
LIGHTING.md | 4 ++
crates/benilla-assets/src/materials.rs | 2 +
.../src/shaders/enhanced_water.wgsl | 7 ++-
.../benilla-assets/src/shaders/fog_hook.wgsl | 63 +++++++++++++++++++
crates/benilla-assets/src/shaders/liquid.wgsl | 8 ++-
.../benilla-assets/src/shaders/terrain.wgsl | 8 ++-
crates/benilla-assets/src/shaders/wdl.wgsl | 6 ++
.../benilla-assets/src/shaders/wow_model.wgsl | 8 ++-
.../benilla-world/src/shaders/static_gx.wgsl | 8 ++-
.../benilla-world/src/shaders/wow_effect.wgsl | 16 +++--
10 files changed, 111 insertions(+), 19 deletions(-)
create mode 100644 crates/benilla-assets/src/shaders/fog_hook.wgsl
diff --git a/LIGHTING.md b/LIGHTING.md
index 59e0f2f1..27a804c4 100644
--- a/LIGHTING.md
+++ b/LIGHTING.md
@@ -35,6 +35,10 @@ is its own module, documented in `WATER.md`.
## Where the code lives
- **Shaders**: `crates/benilla-assets/src/shaders/shadow_hook.wgsl` (shared shadow sampling),
+ `fog_hook.wgsl` (MONKEY p0: the ONE distance-fog law — `fog_linear`, `fog_sample`, `apply_fog`;
+ 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-world/src/shaders/torch_depth.wgsl`, and the lighting lanes inside
`static_gx.wgsl`, `wow_model.wgsl` and `terrain.wgsl`. The three receivers mirror each other; the
comments say where.
diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs
index 08878c86..a2254aa8 100644
--- a/crates/benilla-assets/src/materials.rs
+++ b/crates/benilla-assets/src/materials.rs
@@ -32,6 +32,8 @@ pub fn register_shaders(app: &mut App) {
// MONKEY (p0 MonkeyFrame): `benilla::monkey_frame`, the struct every `WowLight` mirror declares
// after its point table (LIGHTING.md "Invariants").
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 (enhanced water): the optional water module, `benilla::enhanced_water` (WATER.md).
load_shader_library!(app, "shaders/enhanced_water.wgsl");
}
diff --git a/crates/benilla-assets/src/shaders/enhanced_water.wgsl b/crates/benilla-assets/src/shaders/enhanced_water.wgsl
index ac4a27c2..95eb1183 100644
--- a/crates/benilla-assets/src/shaders/enhanced_water.wgsl
+++ b/crates/benilla-assets/src/shaders/enhanced_water.wgsl
@@ -22,6 +22,8 @@
#import bevy_pbr::mesh_view_bindings::{view, globals}
// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table.
#import benilla::monkey_frame
+// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
+#import benilla::fog_hook
@group(#{MATERIAL_BIND_GROUP}) @binding(103) var scene_depth: texture_2d;
@group(#{MATERIAL_BIND_GROUP}) @binding(106) var scene_colour: texture_2d;
@@ -86,8 +88,9 @@ fn water_fog(world_pos: vec3, room_fog: u32) -> vec4 {
}
if colour.w <= 0.5 { return vec4(colour.rgb, 1.0); }
let eye_z = -(view.view_from_world * vec4(world_pos, 1.0)).z;
- let factor = clamp((span.y - eye_z) / max(span.y - span.x, 0.001), 0.0, 1.0);
- return vec4(colour.rgb, factor);
+ // MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl), as a factor; classic is bit-identical.
+ return fog_hook::fog_sample(colour.rgb, span, eye_z, world_pos, view.world_position, true,
+ water_light.monkey);
}
// True where this surface takes the Enhanced path: water (not magma/slime), a tier above
diff --git a/crates/benilla-assets/src/shaders/fog_hook.wgsl b/crates/benilla-assets/src/shaders/fog_hook.wgsl
new file mode 100644
index 00000000..e931ccf9
--- /dev/null
+++ b/crates/benilla-assets/src/shaders/fog_hook.wgsl
@@ -0,0 +1,63 @@
+#define_import_path benilla::fog_hook
+
+// MONKEY (p0 fog hook): the ONE place the distance fog is computed.
+//
+// Every receiver — terrain, wow_model, static_gx, liquid, enhanced_water, wow_effect, wdl — calls
+// in here instead of hand-rolling the fog law, so a new fog model touches this file only (the
+// `shadow_hook.wgsl` pattern). Pure functions: the receivers bind the shared light buffer at
+// different slots (material binding 90, group 2 binding 0, group 1 binding 2, binding 105), so
+// every value arrives as an argument and this file owns no bindings.
+//
+// The classic law is the reference's GL_LINEAR fog on PLANAR eye-Z in gamma space (terrain.wgsl
+// has the full note): `factor = clamp((end - eye_z) / max(end - start, 0.001), 0, 1)`, then
+// `mix(fog_rgb, rgb, factor)`. The expressions below are the ones the receivers carried, in the
+// same order, so the classic result is bit-identical.
+//
+// Extension point for the FOG lane: `fog_sample` branches on `mf.fog3.x` (`MonkeyFrame.fog_model`,
+// 0 classic / 1 modern). The modern arm currently returns the classic result.
+
+#import benilla::monkey_frame::MonkeyFrame
+
+// The classic survival factor: 1 = no fog, 0 = fully fogged.
+fn fog_linear(eye_z: f32, span: vec2) -> f32 {
+ let denom = max(span.y - span.x, 0.001);
+ return clamp((span.y - eye_z) / denom, 0.0, 1.0);
+}
+
+// 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
+// batches, which a new model may attenuate but must not recolour). `span` = (start, end) yd,
+// `eye_z` = planar eye depth (yd), `world_pos` / `camera_pos` in Bevy world space.
+fn fog_sample(
+ fog_rgb: vec3,
+ span: vec2,
+ eye_z: f32,
+ world_pos: vec3,
+ camera_pos: vec3,
+ scene_colour: bool,
+ mf: MonkeyFrame,
+) -> vec4 {
+ let classic = vec4(fog_rgb, fog_linear(eye_z, span));
+ if (mf.fog3.x > 0.5) {
+ // FOG lane: the modern model (height / sun / end-colour / curve fog) goes here, reading
+ // mf.fog0..fog3. Until it lands, the modern arm is the classic law.
+ return classic;
+ }
+ return classic;
+}
+
+// Fog `rgb` in place: `mix(fog colour, rgb, survival)`. Same arguments as `fog_sample`.
+fn apply_fog(
+ rgb: vec3,
+ fog_rgb: vec3,
+ span: vec2,
+ eye_z: f32,
+ world_pos: vec3,
+ camera_pos: vec3,
+ scene_colour: bool,
+ mf: MonkeyFrame,
+) -> vec3 {
+ let f = fog_sample(fog_rgb, span, eye_z, world_pos, camera_pos, scene_colour, mf);
+ return mix(f.rgb, rgb, f.w);
+}
diff --git a/crates/benilla-assets/src/shaders/liquid.wgsl b/crates/benilla-assets/src/shaders/liquid.wgsl
index 293cd96f..56bfd3e7 100644
--- a/crates/benilla-assets/src/shaders/liquid.wgsl
+++ b/crates/benilla-assets/src/shaders/liquid.wgsl
@@ -32,6 +32,8 @@
#import benilla::enhanced_water::{water_active, water_swell, enhanced_water, WaterFragment}
// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table.
#import benilla::monkey_frame
+// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
+#import benilla::fog_hook
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var frames: texture_2d_array;
@group(#{MATERIAL_BIND_GROUP}) @binding(101) var frames_samp: sampler;
@@ -132,9 +134,9 @@ fn apply_fog(rgb: vec3, world_pos: vec3, room_fog: u32) -> vec3 {
return rgb;
}
let eye_z = -(view.view_from_world * vec4(world_pos, 1.0)).z;
- let denom = max(fog_span.y - fog_span.x, 0.001);
- let factor = clamp((fog_span.y - eye_z) / denom, 0.0, 1.0);
- return mix(fog_color.xyz, rgb, factor);
+ // MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl); classic is bit-identical.
+ return fog_hook::apply_fog(rgb, fog_color.xyz, fog_span, eye_z, world_pos, view.world_position,
+ true, wow_light.monkey);
}
@vertex
diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl
index c5394a0b..d4345ae6 100644
--- a/crates/benilla-assets/src/shaders/terrain.wgsl
+++ b/crates/benilla-assets/src/shaders/terrain.wgsl
@@ -20,6 +20,8 @@
#import benilla::shadow_hook
// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table.
#import benilla::monkey_frame
+// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
+#import benilla::fog_hook
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var layer_array: texture_2d_array;
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var alpha_array: texture_2d_array;
@@ -780,9 +782,9 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 {
// the per-pixel form is what survives Bevy's mesh interpolators without a custom slot.
if (wow_light.fog_color.w > 0.5) {
let eye_z = -(view.view_from_world * vec4(in.world_position.xyz, 1.0)).z;
- let denom = max(wow_light.fog_params.y - wow_light.fog_params.x, 0.001);
- let factor = clamp((wow_light.fog_params.y - eye_z) / denom, 0.0, 1.0);
- tuned = mix(wow_light.fog_color.xyz, tuned, factor);
+ // MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl); classic is bit-identical.
+ tuned = fog_hook::apply_fog(tuned, wow_light.fog_color.xyz, wow_light.fog_params.xy, eye_z,
+ in.world_position.xyz, view.world_position, true, wow_light.monkey);
}
// Raw gamma out: the framebuffer holds gamma bytes and blends in gamma like the reference's;
diff --git a/crates/benilla-assets/src/shaders/wdl.wgsl b/crates/benilla-assets/src/shaders/wdl.wgsl
index ea85aa3e..d7a72666 100644
--- a/crates/benilla-assets/src/shaders/wdl.wgsl
+++ b/crates/benilla-assets/src/shaders/wdl.wgsl
@@ -19,6 +19,8 @@
// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table.
#import benilla::monkey_frame
+// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
+#import benilla::fog_hook
/// How far the band reaches inside the far-clip wall: the reference's far-band near plane
/// `farclip - 33.0` (`[0x8101b0]`, about one WDL outer cell).
@@ -81,6 +83,10 @@ fn fragment(in: WdlVsOut) -> WdlFsOut {
var rgb = vec3(1.0);
if (w.fog_color.w > 0.5) {
rgb = w.fog_color.xyz;
+ // MONKEY (p0 fog hook): the hull's own pair (start 0, end 1 yd) through the shared law, so
+ // a new fog model colours the horizon the way it colours the world; classic = the line above.
+ rgb = fog_hook::apply_fog(vec3(1.0), rgb, vec2(0.0, 1.0), eye_z,
+ in.world_position.xyz, view.world_position, true, w.monkey);
}
var out: WdlFsOut;
diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl
index 0f211ca6..c7c9dcb4 100644
--- a/crates/benilla-assets/src/shaders/wow_model.wgsl
+++ b/crates/benilla-assets/src/shaders/wow_model.wgsl
@@ -22,6 +22,8 @@
#import benilla::shadow_hook
// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table.
#import benilla::monkey_frame
+// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
+#import benilla::fog_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.
@@ -1720,13 +1722,13 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
let fog_policy = (u32(m.clutter_fade.z) >> 4u) & 7u;
if (fog_color.w > 0.5 && fog_policy != 4u) {
let eye_z = -(view.view_from_world * vec4(in.world_position.xyz, 1.0)).z;
- let denom = max(fog_span.y - fog_span.x, 0.001);
- let factor = clamp((fog_span.y - eye_z) / denom, 0.0, 1.0);
var fog_rgb = fog_color.xyz;
if (fog_policy == 1u) { fog_rgb = vec3(0.0); }
else if (fog_policy == 2u) { fog_rgb = vec3(1.0); }
else if (fog_policy == 3u) { fog_rgb = vec3(0.50196078); }
- rgb = mix(fog_rgb, rgb, factor);
+ // MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl); classic is bit-identical.
+ rgb = fog_hook::apply_fog(rgb, fog_rgb, fog_span, eye_z, in.world_position.xyz,
+ view.world_position, fog_policy == 0u, wow_light.monkey);
}
diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl
index 0850dc75..a626e709 100644
--- a/crates/benilla-world/src/shaders/static_gx.wgsl
+++ b/crates/benilla-world/src/shaders/static_gx.wgsl
@@ -12,6 +12,8 @@
#import benilla::shadow_hook
// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table.
#import benilla::monkey_frame
+// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
+#import benilla::fog_hook
// Group 0 is Bevy's standard mesh-view bind group (view matrices, directional-light records and
// the shadow textures the retained pass reads).
@@ -1980,9 +1982,9 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 {
fog_span = wow_light.wmo_fog_params.xy;
}
if (fog_color.w > 0.5 && (in.word & WORD_FOG_OFF) == 0u) {
- let denom = max(fog_span.y - fog_span.x, 0.001);
- let factor = clamp((fog_span.y - eye_z) / denom, 0.0, 1.0);
- rgb = mix(fog_color.xyz, rgb, factor);
+ // MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl); classic is bit-identical.
+ rgb = fog_hook::apply_fog(rgb, fog_color.xyz, fog_span, eye_z, in.world_position.xyz,
+ view.world_position, true, wow_light.monkey);
}
// Gamma-space output; alpha pinned 1.0, every draw here is opaque.
return vec4(rgb, 1.0);
diff --git a/crates/benilla-world/src/shaders/wow_effect.wgsl b/crates/benilla-world/src/shaders/wow_effect.wgsl
index e3921b8f..ed16930b 100644
--- a/crates/benilla-world/src/shaders/wow_effect.wgsl
+++ b/crates/benilla-world/src/shaders/wow_effect.wgsl
@@ -14,6 +14,8 @@
#import bevy_render::view::View
// MONKEY (p0 MonkeyFrame): the programme block's struct, mirrored after the point table.
#import benilla::monkey_frame
+// MONKEY (p0 fog hook): the one distance-fog law every receiver calls.
+#import benilla::fog_hook
// Prefix of `lighting::global_light`'s buffer; keep in sync with wow_model.wgsl's copy.
struct WowLight {
@@ -73,6 +75,8 @@ struct VertexOutput {
@location(1) color: vec4,
// Planar eye-Z for fog and the farclip wall, positive in front of the camera.
@location(2) view_z: f32,
+ // MONKEY (p0 fog hook): world position (Bevy space) for the fog hook's modern arm.
+ @location(3) world_pos: vec3,
};
@vertex
@@ -85,6 +89,7 @@ fn vertex(v: Vertex) -> VertexOutput {
out.clip_position = view.clip_from_world * vec4(v.position, 1.0);
// Eye-Z via the full affine transform; its rounding is yard-scale and harmless.
out.view_z = -(view.view_from_world * vec4(v.position, 1.0)).z;
+ out.world_pos = v.position; // MONKEY (p0 fog hook)
#else
// Cam-relative verts: view_from_world is [R | −R·cam] and the rebase already subtracted cam,
// so only the rotation applies.
@@ -95,6 +100,7 @@ fn vertex(v: Vertex) -> VertexOutput {
) * v.position;
out.clip_position = view.clip_from_view * vec4(view_pos, 1.0);
out.view_z = -view_pos.z;
+ out.world_pos = v.position + view.world_position; // MONKEY (p0 fog hook): undo the rebase
#endif
out.uv = v.uv;
out.color = v.color;
@@ -150,19 +156,19 @@ fn fragment(in: VertexOutput) -> @location(0) vec4 {
// Scene fog: the world's linear fog (same start and end, planar eye-Z) in gamma space before
// the blend; only its colour follows the per-blend policy in `wow_params.fog.x`.
if (wow_light.fog_color.w > 0.5 && wow_params.fog.x > 0.5) {
- let denom = max(wow_light.fog_params.y - wow_light.fog_params.x, 0.001);
- let factor = clamp((wow_light.fog_params.y - in.view_z) / denom, 0.0, 1.0);
var fog_rgb = wow_light.fog_color.xyz;
if (wow_params.fog.x > 1.5 && wow_params.fog.x < 2.5) { fog_rgb = vec3(0.0); }
else if (wow_params.fog.x > 2.5 && wow_params.fog.x < 3.5) { fog_rgb = vec3(1.0); }
else if (wow_params.fog.x > 3.5) { fog_rgb = RAIN_FOG_GREY; }
- rgb = mix(fog_rgb, rgb, factor);
+ // MONKEY (p0 fog hook): the shared fog law (fog_hook.wgsl); classic is bit-identical.
+ rgb = fog_hook::apply_fog(rgb, fog_rgb, wow_light.fog_params.xy, in.view_z, in.world_pos,
+ view.world_position, wow_params.fog.x < 1.5, wow_light.monkey);
}
// Rain's forced fog: grey over the draw's own start/end, whatever the scene fog; grey is
// neutral under Mod2x, so this is the streaks' distance fade.
if (wow_params.fog.y > 0.5) {
- let denom = max(wow_params.fog.w - wow_params.fog.z, 0.001);
- let factor = clamp((wow_params.fog.w - in.view_z) / denom, 0.0, 1.0);
+ // MONKEY (p0 fog hook): the draw's own distance fade, always the classic linear law.
+ let factor = fog_hook::fog_linear(in.view_z, wow_params.fog.zw);
rgb = mix(RAIN_FOG_GREY, rgb, factor);
}
#ifdef BLEND_ADD