diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs index 99cb8217..3af07c60 100644 --- a/crates/benilla-app/src/lib.rs +++ b/crates/benilla-app/src/lib.rs @@ -181,6 +181,7 @@ mod ui_unit; mod ui_world_map; mod video; mod vplates; +mod torch_shadow; mod world_backdrop; mod world_shadow; mod world_state; @@ -623,6 +624,7 @@ pub fn run(build: BuildId) -> AppExit { shadow_core::ShadowCorePlugin, character_shadow::CharacterShadowPlugin, world_shadow::WorldShadowPlugin, + torch_shadow::TorchShadowPlugin, )) // The realmlist (decision 1667) — the logon address the login screen edits. Same reason as // VideoPlugin above: it is a CVar knob, so its resource has to exist before `load_config`. diff --git a/crates/benilla-app/src/torch_shadow.rs b/crates/benilla-app/src/torch_shadow.rs new file mode 100644 index 00000000..cfdd29bc --- /dev/null +++ b/crates/benilla-app/src/torch_shadow.rs @@ -0,0 +1,114 @@ +//! MONKEY (torch shadows, #2 spike): a point-light shadow lane over the shared rig. +//! +//! The nearest WMO interior fixture (a `PointLight` with `LightRooms`) is promoted to a PRIVATE-layer +//! (layer 31) shadow-casting `PointLight` PROXY at its position. Bevy renders that proxy's cube +//! shadow map from the layer-31 casters the world lane already builds (walls, columns), so the +//! interior architecture casts into it for free. The receivers sample it via +//! `shadow_hook::torch_shadow` (which scans `clusterable_objects` for the shadow-enabled point light +//! and calls `fetch_point_shadow`). The proxy's own Bevy light is IGNORED by benilla's custom +//! receivers (they light from the `wow_light` Gouraud term, not Bevy clustering) — only its cube map +//! + clusterable entry are used. +//! +//! SPIKE SCOPE: env-gated (`WOW_TORCH_SHADOWS`), ONE torch, reuses the world lane's layer-31 casters +//! — so `worldShadows` must be on for the walls/columns to exist as casters. Milestone: a column +//! throws a shadow on the interior floor that tracks as you move. Not yet a cvar/slider feature. + +use bevy::camera::visibility::RenderLayers; +use bevy::prelude::*; + +use benilla_world::lighting::LightRooms; +use benilla_world::view::WorldCamera; + +use crate::char_select::ClientState; +use crate::shadow_core::{ShadowSet, PLAYER_SHADOW_LAYER}; + +/// How far from the camera to look for an interior fixture to promote (yd). +const TORCH_SEARCH_RADIUS: f32 = 60.0; +/// The proxy light's shadow-cast range (yd) — matches the WMO fixture range (`fx::POINT_LIGHT_RANGE`). +const TORCH_RANGE: f32 = 48.0; + +pub(crate) struct TorchShadowPlugin; + +impl Plugin for TorchShadowPlugin { + fn build(&self, app: &mut App) { + app.init_resource::() + .add_systems(Last, update_torch_shadow.in_set(ShadowSet::Lanes)); + } +} + +/// Marks the proxy point light so it is never itself a promotion candidate. +#[derive(Component)] +struct TorchShadowProxy; + +#[derive(Resource, Default)] +struct TorchProxy(Option); + +/// `WOW_TORCH_SHADOWS=1` — the spike gate (no cvar yet). +fn torch_shadows_enabled() -> bool { + static ON: std::sync::OnceLock = std::sync::OnceLock::new(); + *ON.get_or_init(|| std::env::var("WOW_TORCH_SHADOWS").is_ok_and(|v| v != "0")) +} + +fn update_torch_shadow( + state: Res>, + mut proxy: ResMut, + mut commands: Commands, + cameras: Query<&GlobalTransform, With>, + // Interior WMO fixtures: a `PointLight` that carries a room claim (`LightRooms`). The proxy has + // no `LightRooms`, so it is naturally excluded from the candidate set. + torches: Query<&GlobalTransform, (With, With)>, + mut proxies: Query<&mut Transform, With>, +) { + let active = torch_shadows_enabled() && *state.get() == ClientState::InWorld; + if !active { + if let Some(entity) = proxy.0.take() { + commands.entity(entity).despawn(); + } + return; + } + let Some(cam) = cameras.iter().next().map(GlobalTransform::translation) else { + return; + }; + // The nearest interior fixture within the search radius. + let mut nearest: Option<(f32, Vec3)> = None; + for fixture in torches.iter() { + let pos = fixture.translation(); + let d2 = pos.distance_squared(cam); + if d2 <= TORCH_SEARCH_RADIUS * TORCH_SEARCH_RADIUS + && nearest.is_none_or(|(best, _)| d2 < best) + { + nearest = Some((d2, pos)); + } + } + let Some((_, pos)) = nearest else { + if let Some(entity) = proxy.0.take() { + commands.entity(entity).despawn(); + } + return; + }; + if let Some(entity) = proxy.0 { + if let Ok(mut transform) = proxies.get_mut(entity) { + transform.translation = pos; + } + } else { + let entity = commands + .spawn(( + PointLight { + shadows_enabled: true, + range: TORCH_RANGE, + // Ignored by the custom receivers; only Bevy's cube-map render + clustering read + // the light. A nominal value keeps it from being culled. + intensity: 1000.0, + shadow_depth_bias: 0.06, + shadow_normal_bias: 0.8, + ..default() + }, + Transform::from_translation(pos), + Visibility::Visible, + RenderLayers::layer(PLAYER_SHADOW_LAYER), + TorchShadowProxy, + )) + .id(); + proxy.0 = Some(entity); + } +} diff --git a/crates/benilla-assets/src/shaders/shadow_hook.wgsl b/crates/benilla-assets/src/shaders/shadow_hook.wgsl index 265bb613..dc7327c2 100644 --- a/crates/benilla-assets/src/shaders/shadow_hook.wgsl +++ b/crates/benilla-assets/src/shaders/shadow_hook.wgsl @@ -15,7 +15,8 @@ #import bevy_pbr::{ shadows, - mesh_view_bindings::lights, + mesh_view_bindings::{lights, clusterable_objects}, + mesh_view_types::POINT_LIGHT_FLAGS_SHADOWS_ENABLED_BIT, } // The realtime shadow lightens over the last SHADOW_EDGE_BAND yards of the cascade's max distance so @@ -55,3 +56,23 @@ fn realtime_shadow( let edge_fade = smoothstep(shadow_range - SHADOW_EDGE_BAND, shadow_range, cam_dist); return 1.0 - (1.0 - shadow) * night * (1.0 - edge_fade); } + +// MONKEY (torch shadows #2): the realtime POINT-light shadow factor at `sample_pos` — 1.0 = lit, +// 0.0 = fully shadowed. Scans the view's clusterable objects for shadow-casting point lights (the +// torch-shadow lane promotes the nearest interior fixture to one, `benilla_app::torch_shadow`) and, +// for any whose radius covers the fragment, samples its cube shadow map. `min` over them so the +// darkest occluder wins. A no-op indoors when no such light exists (returns 1.0). Called by the +// INTERIOR receiver paths (the exterior sun's `realtime_shadow` is gated out of interiors). +fn torch_shadow(sample_pos: vec4, normal: vec3) -> f32 { + var shadow = 1.0; + let count = arrayLength(&clusterable_objects.data); + for (var i = 0u; i < count; i = i + 1u) { + if ((clusterable_objects.data[i].flags & POINT_LIGHT_FLAGS_SHADOWS_ENABLED_BIT) != 0u) { + let pr = clusterable_objects.data[i].position_radius; + if (distance(pr.xyz, sample_pos.xyz) < pr.w) { + shadow = min(shadow, shadows::fetch_point_shadow(i, sample_pos, normal)); + } + } + } + return shadow; +} diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl index 1c993e20..bafa890b 100644 --- a/crates/benilla-world/src/shaders/static_gx.wgsl +++ b/crates/benilla-world/src/shaders/static_gx.wgsl @@ -291,9 +291,10 @@ 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. + // MONKEY (shadow hook): EXTERIOR surfaces take the directional sun shadow (fetch + edge/night + // fade, via `benilla::shadow_hook`) — now with the same fades as terrain + models. INTERIOR + // batches (`WORD_INTERIOR`) take the point-light (torch) shadow instead (#2): no sun reaches a + // sealed room, but a promoted torch does. Both are no-ops when their light source is absent. var world_shadow = 1.0; if ((in.word & WORD_INTERIOR) == 0u) { let view_z = (view.view_from_world * in.world_position).z; @@ -306,6 +307,8 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 { wow_light.wmo_fog_params.z, wow_light.fog_params.z, ); + } else { + world_shadow = shadow_hook::torch_shadow(in.world_position, n_lit); } 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