MONKEY: shadow extension hook (benilla::shadow_hook) so receivers stay untouched

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 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-05 18:50:52 +01:00
co-authored by Claude Opus 4.8
parent b2353797d7
commit ab81ddfa06
5 changed files with 130 additions and 92 deletions
+6 -2
View File
@@ -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
@@ -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<f32>,
normal: vec3<f32>,
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);
}
+15 -28
View File
@@ -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<f32>;
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var alpha_array: texture_2d_array<f32>;
@@ -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<f32> {
// 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
@@ -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<f32>(anchor, 1.0),
vec3<f32>(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<f32>(
wow_light.rig_origin[(fade_tag >> 19u) & 0x7ffu].xyz - 2.5 * sun_ray,
1.0,
);
player_shadow = shadow_hook::realtime_shadow(
anchor,
vec3<f32>(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.
+15 -12
View File
@@ -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<f32> {
// 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