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:
co-authored by
Claude Opus 4.8
parent
ab81ddfa06
commit
c4e7660003
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user