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 <noreply@anthropic.com> (cherry picked from commit aade8f16c7798de1320cb189f9d834db8b8f744c)
This commit is contained in:
@@ -70,6 +70,8 @@ fn collect_character_shadows(
|
||||
Option<&RigPart>,
|
||||
&ShadowOccluder,
|
||||
Option<&MeshMaterial3d<WowModelMaterial>>,
|
||||
// MONKEY (B035): the part's mesh bound, for the WMO/doodad caster reach.
|
||||
Option<&bevy::camera::primitives::Aabb>,
|
||||
),
|
||||
Without<BillboardCard>,
|
||||
>,
|
||||
|
||||
@@ -739,6 +739,8 @@ pub(crate) fn collect_entity_geometry(
|
||||
Option<&RigPart>,
|
||||
&ShadowOccluder,
|
||||
Option<&MeshMaterial3d<WowModelMaterial>>,
|
||||
// MONKEY (B035): the part's mesh bound, for the WMO/doodad caster reach.
|
||||
Option<&bevy::camera::primitives::Aabb>,
|
||||
),
|
||||
Without<BillboardCard>,
|
||||
>,
|
||||
@@ -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;
|
||||
|
||||
@@ -191,6 +191,8 @@ fn update_world_shadows(
|
||||
Option<&RigPart>,
|
||||
&ShadowOccluder,
|
||||
Option<&MeshMaterial3d<WowModelMaterial>>,
|
||||
// MONKEY (B035): the part's mesh bound, for the WMO/doodad caster reach.
|
||||
Option<&bevy::camera::primitives::Aabb>,
|
||||
),
|
||||
Without<BillboardCard>,
|
||||
>,
|
||||
|
||||
@@ -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<f32>(0.0);
|
||||
} else if (m.model_flags.x > 0.5 || m.model_flags.z > 0.5) {
|
||||
out.point_lit = vec3<f32>(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,
|
||||
);
|
||||
|
||||
@@ -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<f32>(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<f32> {
|
||||
// `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,
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<u32>,
|
||||
) {
|
||||
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<CutoutBucket> {
|
||||
let r2 = reach * reach;
|
||||
let mut buckets: HashMap<AssetId<Image>, 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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user