MONKEY: torch-shadow point-light lane (spike) + interior hook

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 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-05 22:18:00 +01:00
co-authored by Claude Opus 4.8
parent ab81ddfa06
commit c4e7660003
4 changed files with 144 additions and 4 deletions
+2
View File
@@ -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`.
+114
View File
@@ -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::<TorchProxy>()
.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<Entity>);
/// `WOW_TORCH_SHADOWS=1` — the spike gate (no cvar yet).
fn torch_shadows_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = 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<State<ClientState>>,
mut proxy: ResMut<TorchProxy>,
mut commands: Commands,
cameras: Query<&GlobalTransform, With<WorldCamera>>,
// 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<PointLight>, With<LightRooms>)>,
mut proxies: Query<&mut Transform, With<TorchShadowProxy>>,
) {
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);
}
}
@@ -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<f32>, normal: vec3<f32>) -> 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;
}
@@ -291,9 +291,10 @@ fn fragment(in: GxVsOut) -> @location(0) vec4<f32> {
// 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<f32> {
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