From ab81ddfa06b3979b255d86a9df92a1c233fa3d22 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Sat, 5 Sep 2026 18:50:52 +0100
Subject: [PATCH] MONKEY: shadow extension hook (benilla::shadow_hook) so
receivers stay untouched
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Extract the realtime directional-shadow term (fetch + edge fade + night fade)
into ONE importable WGSL library, benilla::shadow_hook, and route all three
receivers through it. Adding or changing the realtime shadow now touches only
shadow_hook.wgsl; terrain.wgsl / wow_model.wgsl / static_gx.wgsl (the shaders
that blanked the terrain when hand-edited) are no longer edited per shadow
feature. Confirmed in-game (rendering unchanged).
- shadow_hook.wgsl (NEW): `realtime_shadow(sample_pos, normal, view_z, cam_dist,
shadow_range, night) -> factor`. No-op whenever no shadow sun exists (the only
Bevy DirectionalLight benilla spawns is the rig's, so n_directional_lights == 0
exactly when shadows are off) — no shader-def toggle needed. Registered via
bevy `load_shader_library!` in benilla-assets so `#import benilla::shadow_hook`
resolves across crates (verified: the model pipeline composes it cleanly).
- terrain / wow_model: replace the inline fetch+fade with the hook call; keep
their own downstream gating (MCSH suppression + spec gate; rig-skin sample
point + interior exclusion + ambient-preserving apply). Behavior-preserving.
- static_gx: routed through the hook too — tree/building surfaces now get the
SAME edge + night fade terrain/models already had (they lacked it before).
- The duplicated SHADOW_EDGE_BAND const now lives once, in the hook.
Co-Authored-By: Claude Opus 4.8
---
crates/benilla-assets/src/materials.rs | 8 +-
.../src/shaders/shadow_hook.wgsl | 57 ++++++++++++
.../benilla-assets/src/shaders/terrain.wgsl | 43 ++++-----
.../benilla-assets/src/shaders/wow_model.wgsl | 87 ++++++++-----------
.../benilla-world/src/shaders/static_gx.wgsl | 27 +++---
5 files changed, 130 insertions(+), 92 deletions(-)
create mode 100644 crates/benilla-assets/src/shaders/shadow_hook.wgsl
diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs
index 8d09ad38..e1137937 100644
--- a/crates/benilla-assets/src/materials.rs
+++ b/crates/benilla-assets/src/materials.rs
@@ -35,9 +35,9 @@ use bevy::render::render_resource::{
AsBindGroup, BlendComponent, BlendFactor, BlendOperation, BlendState, Buffer, ColorWrites,
CompareFunction, RenderPipelineDescriptor, SpecializedMeshPipelineError,
};
-use bevy::shader::ShaderRef;
+use bevy::shader::{load_shader_library, ShaderRef};
-/// Compile the four WGSL files into the binary and register them under
+/// Compile the WGSL files into the binary and register them under
/// `embedded://benilla_assets/shaders/…`. Call **after** Bevy's `AssetPlugin` (it fills the
/// registry that plugin creates); [`crate::register_asset_loaders`] already does.
pub fn register_shaders(app: &mut App) {
@@ -45,6 +45,10 @@ pub fn register_shaders(app: &mut App) {
bevy::asset::embedded_asset!(app, "shaders/wow_model.wgsl");
bevy::asset::embedded_asset!(app, "shaders/wdl.wgsl");
bevy::asset::embedded_asset!(app, "shaders/liquid.wgsl");
+ // MONKEY (shadow hook): register the importable shadow-contribution library `benilla::shadow_hook`
+ // (loaded eagerly so `#import benilla::shadow_hook` resolves in terrain/model/static_gx). This is
+ // the ONE place the realtime directional-shadow term lives; the receivers just call into it.
+ load_shader_library!(app, "shaders/shadow_hook.wgsl");
}
/// The WDL far-band shader's source, for the law tests that live beside the renderer rather than
diff --git a/crates/benilla-assets/src/shaders/shadow_hook.wgsl b/crates/benilla-assets/src/shaders/shadow_hook.wgsl
new file mode 100644
index 00000000..265bb613
--- /dev/null
+++ b/crates/benilla-assets/src/shaders/shadow_hook.wgsl
@@ -0,0 +1,57 @@
+#define_import_path benilla::shadow_hook
+
+// MONKEY (shadow extension hook): the ONE place the realtime directional-shadow term is computed.
+//
+// benilla's receivers — `terrain.wgsl`, `wow_model.wgsl`, `static_gx.wgsl` — call `realtime_shadow`
+// instead of each hand-rolling the fetch + fades. Adding or changing the realtime shadow now touches
+// THIS file only; the three receiver shaders (the ones that blanked the terrain when edited by hand)
+// stay untouched. The receivers keep their own downstream gating (terrain's MCSH suppression + spec
+// gate, the model's interior/rig-skin exclusion + ambient-preserving apply, static_gx's ambient arm)
+// — those are lighting composition, not shadow sampling, so they belong to each receiver.
+//
+// It is a no-op whenever no shadow sun exists: the only Bevy `DirectionalLight` benilla ever spawns
+// is the shadow rig's (`benilla_app::shadow_core`), so `lights.n_directional_lights == 0` exactly
+// when shadows are off, and the loop below never runs. No shader-def toggle is needed.
+
+#import bevy_pbr::{
+ shadows,
+ mesh_view_bindings::lights,
+}
+
+// The realtime shadow lightens over the last SHADOW_EDGE_BAND yards of the cascade's max distance so
+// it fades in rather than popping at the resolve boundary.
+const SHADOW_EDGE_BAND: f32 = 14.0;
+
+// The realtime directional-shadow factor at `sample_pos`: 1.0 = lit, 0.0 = fully shadowed.
+//
+// - `sample_pos` world-space point to sample the shadow map at — a per-fragment position, or a
+// unit's nudged rig anchor (the caller chooses; that is the rig-skin decision).
+// - `normal` receiver normal, for the slope-aware depth bias.
+// - `view_z` view-space z, for cascade selection.
+// - `cam_dist` distance from the camera to the receiver fragment, for the EDGE fade.
+// - `shadow_range` the cascade's max distance (the `shadowDistance` slider) — where the edge fade ends.
+// - `night` the sun-height strength (0 at night .. 1 by day) — the NIGHT fade.
+//
+// Both fades lighten the result toward 1.0 (no shadow). The caller passes `shadow_range`/`night`
+// from its own light buffer (the receivers declare that buffer three incompatible ways, so the hook
+// takes the scalars as arguments rather than reading it).
+fn realtime_shadow(
+ sample_pos: vec4,
+ normal: vec3,
+ view_z: f32,
+ cam_dist: f32,
+ shadow_range: f32,
+ night: f32,
+) -> f32 {
+ var shadow = 1.0;
+ if (lights.n_directional_lights > 0u) {
+ for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
+ if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
+ shadow = shadows::fetch_directional_shadow(light_id, sample_pos, normal, view_z);
+ break;
+ }
+ }
+ }
+ let edge_fade = smoothstep(shadow_range - SHADOW_EDGE_BAND, shadow_range, cam_dist);
+ return 1.0 - (1.0 - shadow) * night * (1.0 - edge_fade);
+}
diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl
index 2cf73e30..8f8f87fe 100644
--- a/crates/benilla-assets/src/shaders/terrain.wgsl
+++ b/crates/benilla-assets/src/shaders/terrain.wgsl
@@ -32,6 +32,8 @@
mesh_view_bindings::{lights, view},
shadows,
}
+// MONKEY (shadow hook): the realtime directional-shadow term (fetch + edge/night fade) lives here.
+#import benilla::shadow_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;
@@ -41,11 +43,6 @@
// A fully covered character shadow retains 45% of the authored terrain colour.
const SHADOW_SUN_FLOOR: f32 = 0.45;
-// MONKEY (edge fade): the realtime shadow lightens over the last SHADOW_EDGE_BAND yards of the
-// cascade's max distance so it fades in rather than popping at the resolve boundary. The distance
-// is the `shadowDistance` slider, read live from `_wmo_fog[1].z` (packed by build_light_data).
-const SHADOW_EDGE_BAND: f32 = 14.0;
-
// Per-tile Vec4 uniforms packed into ONE buffer (binding 106) — the field order here MUST match the
// Rust `TerrainExtension` struct. Light + fog live in the shared global-light storage buffer (below);
// what's left is just the per-tile layer tiling factor.
@@ -302,31 +299,21 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 {
// Keep the raw fetch for the specular gate below. The realtime map now contains CHARACTERS
// only; MCSH contains the static world's baked blockers. They are independent occluders, so
// the character map must never replace (and thereby brighten) the authored terrain bake.
- var world_shadow = 1.0;
- if (lights.n_directional_lights > 0u) {
- let view_z = (view.view_from_world * in.world_position).z;
- for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
- if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
- world_shadow = shadows::fetch_directional_shadow(
- light_id,
- in.world_position,
- normalize(in.world_normal),
- view_z,
- );
- break;
- }
- }
- }
- // MONKEY: soften the realtime shadow before it darkens the ground — an EDGE fade toward the
- // cascade's max distance (no hard pop at the resolve boundary) and a NIGHT fade by the real sun
- // height (`fog_params.z`, 1 by day → 0 at night, so shadows vanish as the sun sets). Both
- // lighten `world_shadow` toward 1.0 (no shadow). `sun_shadow_strength` is reused for the MCSH
- // fade-back below.
+ // MONKEY (shadow hook): the realtime shadow — fetch + edge fade (toward the `shadowDistance`
+ // range in `_wmo_fog[1].z`) + night fade (`fog_params.z`) — is computed by `benilla::shadow_hook`.
+ // Terrain keeps only its own MCSH suppression + spec gate below; `sun_shadow_strength` feeds the
+ // MCSH fade-back.
+ let view_z = (view.view_from_world * in.world_position).z;
let cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
- let shadow_range = wow_light._wmo_fog[1].z; // the `shadowDistance` slider (yd)
- let edge_fade = smoothstep(shadow_range - SHADOW_EDGE_BAND, shadow_range, cam_dist);
let sun_shadow_strength = wow_light.fog_params.z;
- world_shadow = 1.0 - (1.0 - world_shadow) * sun_shadow_strength * (1.0 - edge_fade);
+ let world_shadow = shadow_hook::realtime_shadow(
+ in.world_position,
+ normalize(in.world_normal),
+ view_z,
+ cam_dist,
+ wow_light._wmo_fog[1].z,
+ sun_shadow_strength,
+ );
let primary = in.primary;
// STEP 4: MCSH baked shadow. On the reference path (pixelShaders+specular) terrain is ONE pass
diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl
index 09fc9543..4f6433ab 100644
--- a/crates/benilla-assets/src/shaders/wow_model.wgsl
+++ b/crates/benilla-assets/src/shaders/wow_model.wgsl
@@ -51,6 +51,8 @@
shadows,
mesh_functions,
}
+// MONKEY (shadow hook): the realtime directional-shadow term (fetch + edge/night fade) lives here.
+#import benilla::shadow_hook
// Our own fragment output, so the SKY lane can force the far depth. Bevy's `forward_io::FragmentOutput`
// is `@location(0) color` and nothing else (bevy_pbr 0.18.1 `forward_io.wgsl`), so this is that struct
@@ -115,11 +117,6 @@ struct ModelParams {
// A fully covered character shadow retains 45% of the authored model lighting.
const SHADOW_SUN_FLOOR: f32 = 0.45;
-// MONKEY (edge fade): the realtime shadow lightens over the last SHADOW_EDGE_BAND yards of the
-// cascade's max distance so it fades in rather than popping. The distance is the `shadowDistance`
-// slider, read live from `wmo_fog_params.z` (packed by build_light_data).
-const SHADOW_EDGE_BAND: f32 = 14.0;
-
// The shared global light (lighting::global_light): ONE storage buffer every material reads, updated
// once/frame in place — replaces the per-material light/fog uniforms the old apply_wow_lighting re-pushed
// each frame. The model reads rows 0-2 (ambient/diffuse/sun) + fog, plus rows 6-12: the disassembled
@@ -981,53 +978,43 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// through `point_diffuse` below like any exterior entity.
let is_rig = m.sun_scale.x >= 1.5;
let lit = select(select(lit_exterior, lit_interior, is_interior), lit_m2_interior, is_rig);
+ // MONKEY (shadow hook): the realtime shadow (fetch + edge/night fade) is computed by
+ // `benilla::shadow_hook`. This lane keeps only the rig-skin SAMPLE-POINT choice + the
+ // interior/rig exclusion + ambient-preserving apply below.
var player_shadow = 1.0;
- if (lights.n_directional_lights > 0u) {
- let view_z = (view.view_from_world * in.world_position).z;
- for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
- if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
-#ifdef WOW_RIG_SKIN
- // A skinned UNIT samples the map ONCE at its rig origin (between the feet),
- // nudged 2.5 units TOWARD the sun, and the whole unit dims uniformly — the
- // reference's own per-unit response. Per-fragment sampling is wrong here: the
- // caster proxy holds a CPU-skinned copy of this very body, so a body fragment
- // reads its own limbs/weapon as occluders (self-shadow speckle no bias fixes on
- // animated silhouettes). Points along the sun ray share a shadow-map texel, so
- // the nudge only excludes occluders within ~2.5 units of the feet ALONG the ray
- // (a fence hugging the unit) — a building or canopy still lands. Gated on the
- // directional-enable flag so an interior-lit unit never takes an exterior dim.
- if (wow_light.light_sun.w > 0.5) {
- let sun_ray = normalize(wow_light.light_sun.xyz);
- let anchor = wow_light.rig_origin[(fade_tag >> 19u) & 0x7ffu].xyz
- - 2.5 * sun_ray;
- player_shadow = shadows::fetch_directional_shadow(
- light_id,
- vec4(anchor, 1.0),
- vec3(0.0, 1.0, 0.0),
- view_z,
- );
- }
-#else
- player_shadow = shadows::fetch_directional_shadow(
- light_id,
- in.world_position,
- wow_normalize(in.world_normal),
- view_z,
- );
-#endif
- break;
- }
- }
- }
- // MONKEY: soften the realtime shadow like the terrain — an EDGE fade toward the cascade's max
- // distance + a NIGHT fade by the real sun height (`fog_params.z`, 1 by day → 0 at night). Both
- // lighten `player_shadow` toward 1.0 (no shadow), so shadows on models fade in at range and
- // vanish at night. No effect when unshadowed (player_shadow already 1.0) — keeps the
- // `worldShadows 0` path byte-identical.
+ let view_z = (view.view_from_world * in.world_position).z;
let shadow_cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
- let shadow_range = wow_light.wmo_fog_params.z; // the `shadowDistance` slider (yd)
- let shadow_edge_fade = smoothstep(shadow_range - SHADOW_EDGE_BAND, shadow_range, shadow_cam_dist);
- player_shadow = 1.0 - (1.0 - player_shadow) * wow_light.fog_params.z * (1.0 - shadow_edge_fade);
+#ifdef WOW_RIG_SKIN
+ // A skinned UNIT samples the map ONCE at its rig origin (between the feet), nudged 2.5 units
+ // TOWARD the sun, and dims uniformly — the reference's per-unit response. Per-fragment sampling
+ // is wrong here: the caster proxy holds a CPU-skinned copy of this very body, so a body fragment
+ // reads its own limbs/weapon as occluders (self-shadow speckle). Gated on the directional-enable
+ // flag so an interior-lit unit never takes an exterior dim (player_shadow stays 1.0).
+ if (wow_light.light_sun.w > 0.5) {
+ let sun_ray = normalize(wow_light.light_sun.xyz);
+ let anchor = vec4(
+ wow_light.rig_origin[(fade_tag >> 19u) & 0x7ffu].xyz - 2.5 * sun_ray,
+ 1.0,
+ );
+ player_shadow = shadow_hook::realtime_shadow(
+ anchor,
+ vec3(0.0, 1.0, 0.0),
+ view_z,
+ shadow_cam_dist,
+ wow_light.wmo_fog_params.z,
+ wow_light.fog_params.z,
+ );
+ }
+#else
+ player_shadow = shadow_hook::realtime_shadow(
+ in.world_position,
+ wow_normalize(in.world_normal),
+ view_z,
+ shadow_cam_dist,
+ wow_light.wmo_fog_params.z,
+ wow_light.fog_params.z,
+ );
+#endif
// The realtime map blocks only the directional sun. Preserve the authored ambient/probe
// contribution instead of multiplying the whole lighting result; the latter makes interiors,
// point-lit props, and shadow-side characters globally too dark.
diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl
index d02a735e..1c993e20 100644
--- a/crates/benilla-world/src/shaders/static_gx.wgsl
+++ b/crates/benilla-world/src/shaders/static_gx.wgsl
@@ -31,6 +31,8 @@
mesh_view_bindings::{lights, view},
shadows,
}
+// MONKEY (shadow hook): the realtime directional-shadow term (fetch + edge/night fade) lives here.
+#import benilla::shadow_hook
// Group 0 is Bevy's standard mesh-view bind group (view matrices, directional-light records and
// the shadow textures the retained pass reads).
@@ -289,20 +291,21 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 {
// The retained path uses the same Bevy directional shadow map as terrain and entity models.
// `static_gx` used to have no mesh-view shadow bindings, which made every Stormwind WMO act
// as if shadows were disabled even though the caster map was populated.
+ // MONKEY (shadow hook): the realtime shadow (fetch + edge/night fade, via `benilla::shadow_hook`)
+ // — now with the same edge/night fade as terrain + models. Interior batches (`WORD_INTERIOR`)
+ // stay excluded (no sun reaches a sealed room); the hook is a no-op when no shadow sun exists.
var world_shadow = 1.0;
- if (lights.n_directional_lights > 0u && (in.word & WORD_INTERIOR) == 0u) {
+ if ((in.word & WORD_INTERIOR) == 0u) {
let view_z = (view.view_from_world * in.world_position).z;
- for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
- if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
- world_shadow = shadows::fetch_directional_shadow(
- light_id,
- in.world_position,
- n_lit,
- view_z,
- );
- break;
- }
- }
+ let cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
+ world_shadow = shadow_hook::realtime_shadow(
+ in.world_position,
+ n_lit,
+ view_z,
+ cam_dist,
+ wow_light.wmo_fog_params.z,
+ wow_light.fog_params.z,
+ );
}
let shadow_term = mix(SHADOW_SUN_FLOOR, 1.0, world_shadow);
// Keep ambient energy when the realtime map blocks the sun. The retained pass also carries