From cbfcc3cadad9f6652acd7ee350c7fff2d39fd71c Mon Sep 17 00:00:00 2001 From: pkuzic Date: Thu, 8 Oct 2026 18:19:15 +0100 Subject: [PATCH] MONKEY (r17 V1): B035 - world shadow casters reach-tested by batch bounds, not placement origin (Stormwind gate pop); exterior WMO point lights summed per fragment (blocky hoof glow on bridge paving) Co-Authored-By: Claude Opus 5.5 (cherry picked from commit aade8f16c7798de1320cb189f9d834db8b8f744c) --- crates/benilla-app/src/character_shadow.rs | 2 + crates/benilla-app/src/shadow_core.rs | 21 ++++++- crates/benilla-app/src/world_shadow.rs | 2 + .../benilla-assets/src/shaders/wow_model.wgsl | 10 +++- .../benilla-world/src/shaders/static_gx.wgsl | 16 +++++- crates/benilla-world/src/static_gx/mod.rs | 1 + crates/benilla-world/src/static_gx/shadow.rs | 56 ++++++++++++++++--- 7 files changed, 95 insertions(+), 13 deletions(-) diff --git a/crates/benilla-app/src/character_shadow.rs b/crates/benilla-app/src/character_shadow.rs index cc4cf4dd..b5ed113a 100644 --- a/crates/benilla-app/src/character_shadow.rs +++ b/crates/benilla-app/src/character_shadow.rs @@ -70,6 +70,8 @@ fn collect_character_shadows( Option<&RigPart>, &ShadowOccluder, Option<&MeshMaterial3d>, + // MONKEY (B035): the part's mesh bound, for the WMO/doodad caster reach. + Option<&bevy::camera::primitives::Aabb>, ), Without, >, diff --git a/crates/benilla-app/src/shadow_core.rs b/crates/benilla-app/src/shadow_core.rs index 388affc4..9f3baf82 100644 --- a/crates/benilla-app/src/shadow_core.rs +++ b/crates/benilla-app/src/shadow_core.rs @@ -739,6 +739,8 @@ pub(crate) fn collect_entity_geometry( Option<&RigPart>, &ShadowOccluder, Option<&MeshMaterial3d>, + // MONKEY (B035): the part's mesh bound, for the WMO/doodad caster reach. + Option<&bevy::camera::primitives::Aabb>, ), Without, >, @@ -754,7 +756,7 @@ pub(crate) fn collect_entity_geometry( mut built_parts: Option<&mut EntityHashSet>, ) -> (u32, u32) { let (mut admitted, mut rejected) = (0u32, 0u32); - for (entity, pick, part, global, rig_part, occluder, _material) in parts.iter() { + for (entity, pick, part, global, rig_part, occluder, _material, aabb) in parts.iter() { if !casts_realtime_shadow(part.kind, part.blend, want_creatures, want_environment) { continue; } @@ -775,7 +777,22 @@ pub(crate) fn collect_entity_geometry( .and_then(|rig| palettes.slot_origin(rig.slot)) }); let Some(anchor) = anchor else { continue }; - if anchor.distance_squared(light_position) > reach * reach { + // MONKEY (B035 shadow pop): a WMO/doodad part's anchor is its PLACEMENT origin (one point + // for a whole city), so reach is measured to its transformed bound instead — the same + // law the retained lane uses ([`benilla_world::static_gx::caster_in_reach`]). Creatures + // and unbounded parts keep the anchor test. + let in_reach = match (part.kind, global, aabb, rig_part) { + (ModelKind::Doodad | ModelKind::Wmo, Some(global), Some(aabb), None) => { + benilla_world::static_gx::caster_in_reach( + &global.compute_transform(), + Some(aabb), + light_position, + reach, + ) + } + _ => anchor.distance_squared(light_position) <= reach * reach, + }; + if !in_reach { continue; } admitted += 1; diff --git a/crates/benilla-app/src/world_shadow.rs b/crates/benilla-app/src/world_shadow.rs index 7a1ecd9b..261a577c 100644 --- a/crates/benilla-app/src/world_shadow.rs +++ b/crates/benilla-app/src/world_shadow.rs @@ -191,6 +191,8 @@ fn update_world_shadows( Option<&RigPart>, &ShadowOccluder, Option<&MeshMaterial3d>, + // MONKEY (B035): the part's mesh bound, for the WMO/doodad caster reach. + Option<&bevy::camera::primitives::Aabb>, ), Without, >, diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index fbe931a2..8c81182a 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -1209,7 +1209,8 @@ fn vertex(vertex: WowVertex) -> WowVsOut { // the sum was always `eval(pick(..))`, it is just no longer inlined. let sel = wmo_exterior_pick(out.world_position.xyz); out.ext_sel = sel; - out.point_lit = point_light_eval(sel, out.world_position.xyz, out.world_normal); + // MONKEY (B035 per-fragment wmo points): summed per FRAGMENT now, as in `static_gx.wgsl`. + out.point_lit = vec3(0.0); } else if (m.model_flags.x > 0.5 || m.model_flags.z > 0.5) { out.point_lit = vec3(0.0); out.ext_sel = EXT_SEL_NONE; @@ -1635,10 +1636,15 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut { // normal every other lit term here uses, so the normal-offset sample leaves the skin the way // the interior entity lane's does. var point_diffuse = in.point_lit; + // MONKEY (B035 per-fragment wmo points): an EXTERIOR-class WMO batch sums its picked lights + // per fragment, day and night — MIRRORED from `static_gx.wgsl` (that file has the why). + if (m.model_flags.x > 0.5 && m.model_flags.z < 0.5) { + point_diffuse = point_light_eval(in.ext_sel, in.world_position.xyz, n_lit); + } let ext_night_w = select(0.0, clamp(1.0 - wow_light.fog_params.z, 0.0, 1.0), torch_ext_on()); if (ext_night_w > 0.0) { point_diffuse = mix( - in.point_lit, + point_diffuse, point_light_eval_shadowed(in.ext_sel, in.world_position.xyz, n_lit), ext_night_w, ); diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl index 4ae5a4b2..ad42eca3 100644 --- a/crates/benilla-world/src/shaders/static_gx.wgsl +++ b/crates/benilla-world/src/shaders/static_gx.wgsl @@ -1362,7 +1362,9 @@ fn vertex(v: GxVertex) -> GxVsOut { // still, not two — the sum was always `eval(pick(...))`, it is just no longer inlined. let sel = wmo_exterior_pick(world); out.ext_sel = sel; - out.point_lit = point_light_eval(sel, world, v.normal); + // MONKEY (B035 per-fragment wmo points): the SUM moved to the fragment stage (see there); + // only the pick stays per vertex, so this lane still walks the table once per vertex. + out.point_lit = vec3(0.0); } else { // MONKEY (ext light k12): `box = 0` — an exterior doodad/MODD prop is its own draw unit and // ranks from its baked PLACEMENT origin, exactly as it did before the widening. @@ -1432,10 +1434,20 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 { // `torch_ext_on()` is the CPU's one-bit verdict (`exteriorShadows` && night && ≥1 promoted // exterior fixture), so with the cvar off the branch is dead too. var point_lit = in.point_lit; + // MONKEY (B035 per-fragment wmo points): an EXTERIOR WMO surface evaluates its picked lights + // PER FRAGMENT, day and night. Its triangles are paving-slab sized (the Valley of Heroes + // bridge: a few yards a side), and the `1/(0.7d + 0.03d^2)` falloff of a light hanging a + // fraction of a yard over them — a glowing mount's hooves, a spell — is all peak and no + // shoulder at that scale, so the Gouraud sum lit whichever slab corners happened to sit under + // the light: a pool cut along the tile edges that jumped as the light moved. The vertex pick + // (`in.ext_sel`, flat) is unchanged, so the SET of lights and its cell agreement are too. + if ((in.word & WORD_WMO) != 0u && (in.word & WORD_INTERIOR) == 0u) { + point_lit = point_light_eval(in.ext_sel, in.world_position.xyz, n_lit); + } let ext_night_w = select(0.0, clamp(1.0 - wow_light.fog_params.z, 0.0, 1.0), torch_ext_on()); if (ext_night_w > 0.0) { point_lit = mix( - in.point_lit, + point_lit, point_light_eval_shadowed(in.ext_sel, in.world_position.xyz, n_lit), ext_night_w, ); diff --git a/crates/benilla-world/src/static_gx/mod.rs b/crates/benilla-world/src/static_gx/mod.rs index a9dbc73b..77b30e7c 100644 --- a/crates/benilla-world/src/static_gx/mod.rs +++ b/crates/benilla-world/src/static_gx/mod.rs @@ -25,6 +25,7 @@ mod render; mod shadow; // MONKEY (world shadows): CPU triangle collection for the static-world shadow caster mod torch_depth; // MONKEY (torch shadows Phase 1): the per-fixture depth-map render + its targets pub use shadow::CutoutBucket; // MONKEY (world shadows): per-leaf-texture alpha-cutout caster group +pub use shadow::caster_in_reach; // MONKEY (B035): bounds-based caster reach, shared with the entity lane pub use torch_depth::TorchShadowViews; // MONKEY (torch shadows Phase 1): the app→render publication // MONKEY (torch owner exclusion): the light→caster ownership key, written at the light spawn // sites (`terrain_stream::spawn`, `benilla_app::entities::carried_light`) and read by both diff --git a/crates/benilla-world/src/static_gx/shadow.rs b/crates/benilla-world/src/static_gx/shadow.rs index ed86e130..93eedf3b 100644 --- a/crates/benilla-world/src/static_gx/shadow.rs +++ b/crates/benilla-world/src/static_gx/shadow.rs @@ -15,8 +15,33 @@ use bevy::image::Image; use bevy::platform::collections::HashMap; use bevy::prelude::*; +use bevy::camera::primitives::Aabb; + use super::{GxCell, StaticGx}; +/// MONKEY (B035 shadow pop): does a retained batch come within `reach` of `center`? Measured to +/// the batch's TRANSFORMED bounding sphere, not to its placement anchor. A WMO batch's anchor is +/// the placement ORIGIN — one point for the whole of Stormwind, ~230 yd from the Elwynn gate — so +/// the old anchor test dropped the gate's walls from the caster set until the camera came within +/// `reach` of the city centre, and then added the whole city at once: the gate shadow popped from +/// absent to dense in one step (bug B035). Same law as the torch lane's `torch_bound_in_range`. +/// A batch with no bounds keeps the anchor test (the old behaviour), so nothing is lost. +pub fn caster_in_reach( + transform: &Transform, + local_aabb: Option<&Aabb>, + center: Vec3, + reach: f32, +) -> bool { + match local_aabb { + Some(aabb) => { + let origin = transform.transform_point(Vec3::from(aabb.center)); + let radius = (Vec3::from(aabb.half_extents) * transform.scale.abs()).length(); + origin.distance_squared(center) <= (reach + radius) * (reach + radius) + } + None => transform.translation.distance_squared(center) <= reach * reach, + } +} + /// One alpha-tested foliage caster group: every resident cutout batch that shares a single leaf /// texture, merged into one world-space triangle list carrying UVs. The character-shadow system /// drives one layer-31 caster entity + [`super::super`]-side `CutoutShadowCasterMaterial` per @@ -38,7 +63,7 @@ pub struct CutoutBucket { impl StaticGx { /// Append world-space (Bevy) shadow-caster triangles for resident static geometry whose - /// placement anchor is within `reach` of `center`, onto the caller's buffers (the character + /// bounds come within `reach` of `center` ([`caster_in_reach`]), onto the caller's buffers (the character /// system recycles them across rebuilds). Whole triangles referencing out-of-range indices are /// dropped — a truncated submesh must never rewire a later triangle into garbage (mirrors /// `character_shadow::append_triangles`). @@ -53,11 +78,10 @@ impl StaticGx { positions: &mut Vec<[f32; 3]>, indices: &mut Vec, ) { - let r2 = reach * reach; let mut push = |cell: &GxCell| { for item in &cell.items { - // Distance gate on the placement anchor BEFORE any vertex work. - if item.transform.translation.distance_squared(center) > r2 { + // Distance gate on the batch's own bounds BEFORE any vertex work (B035). + if !caster_in_reach(&item.transform, item.local_aabb.as_ref(), center, reach) { continue; } // MONKEY (world shadows): the SOLID caster skips alpha-cutout foliage — a leaf card @@ -101,12 +125,11 @@ impl StaticGx { /// layer-31 caster per bucket, so a canopy discards its transparent texels in the shadow pass /// and casts a leaf-SHAPED silhouette. Grouping by texture is what lets a single draw carry /// one sheet: a zone's canopies draw from only a handful of sheets, so this yields a handful - /// of buckets. Range-gates on the placement anchor exactly like [`Self::append_shadow_triangles`]. + /// of buckets. Range-gates on the batch bounds exactly like [`Self::append_shadow_triangles`]. /// /// A cutout batch without a texture handle, or whose UVs don't parallel its positions, is /// skipped (it can't be alpha-tested — better no shadow than a wrong solid box). pub fn collect_cutout_shadow_triangles(&self, center: Vec3, reach: f32) -> Vec { - let r2 = reach * reach; let mut buckets: HashMap, CutoutBucket> = HashMap::new(); let mut push = |cell: &GxCell| { for item in &cell.items { @@ -117,7 +140,7 @@ impl StaticGx { else { continue; }; - if item.transform.translation.distance_squared(center) > r2 { + if !caster_in_reach(&item.transform, item.local_aabb.as_ref(), center, reach) { continue; } let geometry = &item.geometry; @@ -162,3 +185,22 @@ impl StaticGx { buckets.into_values().collect() } } + +#[cfg(test)] +mod tests { + use super::*; + + // MONKEY (B035): a big placement whose ORIGIN is far but whose batch stands near the camera + // casts; a batch whose bounds are wholly out of reach does not; no bounds = the anchor law. + #[test] + fn caster_reach_measures_the_batch_bounds_not_the_placement_origin() { + let city = Transform::from_translation(Vec3::new(230.0, 0.0, 0.0)); + let gate = Aabb::from_min_max(Vec3::new(-235.0, 0.0, -10.0), Vec3::new(-225.0, 30.0, 10.0)); + assert!(caster_in_reach(&city, Some(&gate), Vec3::ZERO, 130.0)); + assert!(!caster_in_reach(&city, None, Vec3::ZERO, 130.0)); + let far = Aabb::from_min_max(Vec3::new(200.0, 0.0, -5.0), Vec3::new(210.0, 10.0, 5.0)); + assert!(!caster_in_reach(&city, Some(&far), Vec3::ZERO, 130.0)); + let near = Transform::from_translation(Vec3::new(20.0, 0.0, 0.0)); + assert!(caster_in_reach(&near, None, Vec3::ZERO, 130.0)); + } +}