From c4e76600031348f80a1c0fdec079ec88778b760a Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Sat, 5 Sep 2026 22:18:00 +0100
Subject: [PATCH] MONKEY: torch-shadow point-light lane (spike) + interior hook
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A point-light shadow lane over the shared rig: the nearest WMO interior fixture
is promoted to a private-layer (31) shadow-casting PointLight proxy, so it casts
the layer-31 world casters into a Bevy cube shadow map; the receiver hook gains
`torch_shadow` (scans clusterable_objects for the shadow-enabled point light and
samples fetch_point_shadow), and static_gx's INTERIOR branch calls it.
Env-gated (WOW_TORCH_SHADOWS), off by default, one torch. FINDING: WMO interiors
are baked-lit (INT batch class = the MOCV bake IS the room's light, including its
baked shadows), so a realtime torch shadow of the STATIC architecture is
redundant and doesn't show via the lit_nl path INT interiors ignore. Committed as
the foundation for dynamic interior lighting (replacing the interior bake with
live torch lighting + realtime shadows) — the next step.
Co-Authored-By: Claude Opus 4.8
---
crates/benilla-app/src/lib.rs | 2 +
crates/benilla-app/src/torch_shadow.rs | 114 ++++++++++++++++++
.../src/shaders/shadow_hook.wgsl | 23 +++-
.../benilla-world/src/shaders/static_gx.wgsl | 9 +-
4 files changed, 144 insertions(+), 4 deletions(-)
create mode 100644 crates/benilla-app/src/torch_shadow.rs
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