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;