From c573ecc5d0fd52c7dcefa426678658bb9624ebd9 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Sat, 3 Oct 2026 21:07:04 +0100 Subject: [PATCH] MONKEY (gfx): choose the nearest shadow map for overlapping lights World and entity receivers now choose the nearest ready torch map within the existing tolerance. Previously an earlier neighbouring fixture won even when the light's own map was present at its exact position. Add an opt-in attached spell-light capture fixture and correlation trace. Verified on Windows with the app library gate, play build and inn/blacksmith captures. The inn trace reproduces first slot 3 versus exact-position slot 11. Visual 60056 movement and doorway transitions still require an in-game retest. Co-Authored-By: GPT-6 Astra (Codex) --- crates/benilla-app/src/capture/mod.rs | 6 ++ crates/benilla-app/src/capture/spell_light.rs | 98 +++++++++++++++++++ .../benilla-assets/src/shaders/wow_model.wgsl | 20 +++- .../benilla-world/src/shaders/static_gx.wgsl | 20 +++- 4 files changed, 134 insertions(+), 10 deletions(-) create mode 100644 crates/benilla-app/src/capture/spell_light.rs diff --git a/crates/benilla-app/src/capture/mod.rs b/crates/benilla-app/src/capture/mod.rs index 6fd190d5..e6130035 100644 --- a/crates/benilla-app/src/capture/mod.rs +++ b/crates/benilla-app/src/capture/mod.rs @@ -78,6 +78,7 @@ mod probe_vendor_swap; mod probes; mod realm_list; mod scenarios; +mod spell_light; use crate::run_mode::CaptureMode; pub(crate) use depth_probe::DepthProbePlugin; use fixtures::{seed_perf_crowd, seed_ui_fixture, seed_wind_player}; @@ -680,6 +681,11 @@ impl Plugin for CapturePlugin { }) .add_systems(Update, pin_scene.in_set(WorldStage::Present)) .add_systems(Update, seed_wind_player) + .add_systems(Update, spell_light::seed) + .add_systems( + Last, + spell_light::trace.after(crate::shadow_core::ShadowSet::Lanes), + ) // MONKEY (perf): the probe crowd (`WOW_PERF_CROWD`). .add_systems(Update, seed_perf_crowd) // Before `UnitFeed`: the seed stands in for wire data live play delivers on earlier diff --git a/crates/benilla-app/src/capture/spell_light.rs b/crates/benilla-app/src/capture/spell_light.rs new file mode 100644 index 00000000..69aa373d --- /dev/null +++ b/crates/benilla-app/src/capture/spell_light.rs @@ -0,0 +1,98 @@ +//! MONKEY (gfx): `WOW_CAPTURE_SPELL_LIGHT=x,y,z` adds a held spell-light fixture at WoW coords. +//! Exercises the real envelope, room claims and shadow budget without a server or spell assets. +//! The trace compares the legacy first-within-one-yard shadow lookup with the nearest match. + +use crate::entities::{ + spell_fx::{SpellLight, SpellLightMode}, + HeldLight, +}; +use benilla_assets::coords::wow_to_bevy; +use benilla_world::{ + lighting::{ResolvedPointLights, SpellFxLight, SyntheticFireLight}, + static_gx::{LightOwner, TorchShadowViews}, + terrain_stream::point_light, +}; +use bevy::prelude::*; + +pub(super) fn seed(mut commands: Commands, mut seeded: Local) { + if *seeded { + return; + } + *seeded = true; + let Ok(value) = std::env::var("WOW_CAPTURE_SPELL_LIGHT") else { + return; + }; + let coords: Vec = value + .split(',') + .filter_map(|v| v.trim().parse().ok()) + .collect(); + let [x, y, z] = coords.as_slice() else { + panic!("WOW_CAPTURE_SPELL_LIGHT needs x,y,z in WoW coordinates"); + }; + let at = wow_to_bevy([*x, *y, *z]); + let parent = commands + .spawn((Transform::from_translation(at), Visibility::default())) + .id(); + let mut light = point_light([1.0, 0.3, 0.05], 1.0); + let base = light.intensity; + light.intensity = 0.0; + let entity = commands + .spawn(( + light, + Transform::IDENTITY, + Visibility::default(), + SyntheticFireLight, + SpellFxLight, + HeldLight, + SpellLight::new(base, 0.0, SpellLightMode::Kit), + LightOwner::Instance(parent), + )) + .id(); + commands.entity(parent).add_child(entity); + info!("capture-spell-light: spawned {entity} wow [{x},{y},{z}] bevy {at:?}"); +} + +pub(super) fn trace( + points: Res, + shadows: Res, + mut previous: Local>, +) { + if std::env::var_os("WOW_CAPTURE_SPELL_LIGHT").is_none() { + return; + } + let mut mismatches = Vec::new(); + for (point, light) in points.as_slice().iter().enumerate() { + if light.lane < 0.5 { + continue; + } + let candidates: Vec<_> = shadows + .positions + .iter() + .enumerate() + .take(shadows.count as usize) + .filter(|(i, pos)| pos.w > 0.0 && shadows.caster_meshes[*i].is_some()) + .map(|(i, pos)| (i, pos.truncate().distance_squared(light.position))) + .filter(|(_, d)| *d < 1.0) + .collect(); + let Some(&(first, _)) = candidates.first() else { + continue; + }; + let &(nearest, distance) = candidates + .iter() + .min_by(|a, b| a.1.total_cmp(&b.1)) + .unwrap(); + if first != nearest { + mismatches.push((point, first, nearest)); + if !previous.contains(&(point, first, nearest)) { + info!("capture-spell-light: point {point} at {:?}: first slot {first}, nearest slot {nearest}, nearest distance {distance:.6} yd squared (CPU publication; GPU readiness not asserted)", light.position); + } + } + } + if *previous != mismatches { + info!( + "capture-spell-light: {} ambiguous first-match rows", + mismatches.len() + ); + *previous = mismatches; + } +} diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index 88d3decc..fbe931a2 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -248,7 +248,7 @@ fn torch_face(d: vec3) -> u32 { // MONKEY (torch shadows Phase 3A): this interior fixture's OWN cast shadow on an ENTITY fragment — // exactly static_gx.wgsl's `torch_surface_shadow` (keep in sync): correlate the `wow_light` -// fixture at `light_pos` to a promoted torch (position match within 1 yd), pick the cube face +// fixture at `light_pos` to a promoted torch (nearest position within 1 yd), pick the cube face // facing the fragment, and sample that layer through the shared projector. 1.0 (unshadowed) when // no map matches — the lane is off, or the fixture was not among the ≤4 promoted. // @@ -263,11 +263,22 @@ fn torch_entity_shadow_at(light_pos: vec3, P: vec3, N: vec3, fade } // Normal-offset the sample point out of the body (see TORCH_NORMAL_OFFSET). let Ps = P + N * TORCH_NORMAL_OFFSET; - for (var i = 0u; i < torch_table.count.x; i = i + 1u) { + // MONKEY (gfx): mirror the retained receiver's nearest-fixture correlation. + var nearest = torch_table.count.x; + var nearest_d2 = 1.0; + for (var candidate = 0u; candidate < torch_table.count.x; candidate += 1u) { // MONKEY (static torch cache): holes and pending uploads never sample stale layers. - if (torch_table.positions[i].w <= 0.0) { continue; } + if (torch_table.positions[candidate].w <= 0.0) { continue; } + let delta = torch_table.positions[candidate].xyz - light_pos; + let d2 = dot(delta, delta); + if (d2 < nearest_d2) { + nearest = candidate; + nearest_d2 = d2; + } + } + if (nearest < torch_table.count.x) { + let i = nearest; let fixture = torch_table.positions[i].xyz; - if (distance(fixture, light_pos) < 1.0) { let face = torch_face(Ps - fixture); let layer = i * 6u + face; // MONKEY (live bank rank): count.z is CPU-ready-filtered; holes must not consume @@ -293,7 +304,6 @@ fn torch_entity_shadow_at(light_pos: vec3, P: vec3, N: vec3, fade // into that same weight (see the function). `w * strength` rather than a second `mix` // because the two are the same expression. return mix(1.0, s, torch_table.positions[i].w * torch_strength()); - } } return 1.0; } diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl index fb968169..861463af 100644 --- a/crates/benilla-world/src/shaders/static_gx.wgsl +++ b/crates/benilla-world/src/shaders/static_gx.wgsl @@ -405,7 +405,7 @@ fn torch_face(d: vec3) -> u32 { } // This fixture's OWN cast shadow: correlate the `wow_light` fixture at `light_pos` to a -// promoted torch (position match within 1 yd), pick the cube face facing the fragment, and sample +// promoted torch (nearest position within 1 yd), pick the cube face facing the fragment, and sample // that layer. 1.0 (unshadowed) when no map matches, or when TORCH_SHADOWS is off (wow_model's copy // of the caller never sets it). // @@ -426,11 +426,22 @@ fn torch_map_at(light_pos: vec3, P: vec3, N: vec3, fade_radius: f if (torch_table.count.x == 0u) { return 1.0; } - for (var i = 0u; i < torch_table.count.x; i = i + 1u) { + // MONKEY (gfx): a nearby spell light must not steal an earlier fixture's map. + var nearest = torch_table.count.x; + var nearest_d2 = 1.0; + for (var candidate = 0u; candidate < torch_table.count.x; candidate += 1u) { // MONKEY (static torch cache): holes and pending uploads never sample stale layers. - if (torch_table.positions[i].w <= 0.0) { continue; } + if (torch_table.positions[candidate].w <= 0.0) { continue; } + let delta = torch_table.positions[candidate].xyz - light_pos; + let d2 = dot(delta, delta); + if (d2 < nearest_d2) { + nearest = candidate; + nearest_d2 = d2; + } + } + if (nearest < torch_table.count.x) { + let i = nearest; let fixture = torch_table.positions[i].xyz; - if (distance(fixture, light_pos) < 1.0) { let face = torch_face(P - fixture); let layer = i * 6u + face; // MONKEY (live bank rank): count.z is CPU-ready-filtered; holes must not consume @@ -457,7 +468,6 @@ fn torch_map_at(light_pos: vec3, P: vec3, N: vec3, fade_radius: f // into that same weight (see the function). `w * strength` rather than a second `mix` // because the two are the same expression. return mix(1.0, s, torch_table.positions[i].w * torch_strength()); - } } #endif return 1.0;