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