diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml index 724c212b..0966023f 100644 --- a/crates/benilla-app/assets/ui/OptionsFrame.xml +++ b/crates/benilla-app/assets/ui/OptionsFrame.xml @@ -687,6 +687,10 @@ .. "following the sun, and switches off the baked terrain shadows they replace. " .. "Independent of Real Character Shadows. This uses additional GPU time and memory." + BENILLA_TOOLTIP_SHADOW_DISTANCE = + "How far realtime shadows are drawn, in yards. Higher covers more ground but spreads " + .. "the shadow map thinner (softer edges) and costs more. Applies on Okay/Apply." + OPTIONS_PAGE_ROWS = { Controls = { "RowStickyTarget", "RowBlockTrades", "RowAutoLoot", "RowInvertMouse", @@ -699,7 +703,7 @@ }, Graphics = { "RowUiScale", "RowFarclip", "RowWorldDetail", - "RowDisplayMode", "RowVerticalSync", "RowCharacterShadows", "RowWorldShadows", "RowRenderScale", + "RowDisplayMode", "RowVerticalSync", "RowCharacterShadows", "RowWorldShadows", "RowShadowDistance", "RowRenderScale", }, Nameplates = { "RowEnemyPlates", "RowFriendlyPlates", @@ -2547,11 +2551,28 @@ "BENILLA_TOOLTIP_WORLD_SHADOWS") - + + + -- Realtime-shadow render distance (yd): the cascade + -- range + caster reach. 40-200, matching the CVar's + -- own clamp (SHADOW_DISTANCE_RANGE). DEFERRED to + -- Apply so a drag doesn't re-fit the cascade + recollect + -- casters on every tick (one rebuild on commit instead). + OptionsRow_OnLoad(self, "shadowDistance", "Shadow Distance", + "BENILLA_TOOLTIP_SHADOW_DISTANCE") + OptionsSliderRow_Setup(self, 40, 200, 10, "yards") + self.deferred = 1 + + + + + + + -- Render Scale (decision 1639) -- benilla's own -- row, no 1.12 counterpart: the reference renders diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs index b6c3f6b3..076154a8 100644 --- a/crates/benilla-app/src/cvars.rs +++ b/crates/benilla-app/src/cvars.rs @@ -54,6 +54,7 @@ use crate::ui_loot::LootConfig; use crate::ui_script::UiScaleCvar; use crate::video::VideoConfig; use crate::vplates::VPlateMode; +use crate::shadow_core::SHADOW_DISTANCE_RANGE; use crate::world_backdrop::{RenderScale, RENDER_SCALE_RANGE}; use benilla_ui::script::UiScript; use benilla_ui::widget::MINIMAP_ZOOM_LEVELS; @@ -591,6 +592,14 @@ pub(crate) const REGISTERED: &[Registered] = &[ // players/NPCs/creatures/mounts cast realtime silhouettes instead of the legacy oval blob. same("worldShadows", "0"), same("characterShadows", "0"), + // Realtime-shadow render distance in yards (the shadow-map cascade range + caster reach). + // benilla's own — the reference has no realtime shadow to size. Clamped to SHADOW_DISTANCE_RANGE. + ours( + "shadowDistance", + "80", + "1900: benilla's own realtime-shadow render-distance slider; the reference bakes MCSH and \ + has no cascade to size", + ), // **Display mode** (decisions 1627, 1650) — 1.12's own `gxWindow`, worn since 1650 as modern // Classic's two-entry *Display Mode* dropdown rather than 1.12's *Windowed Mode* checkbox: the // two states 1627 settled on ARE that client's two (its own `Graphics.lua` builds the list from @@ -1072,6 +1081,10 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool { "gxvsync" => knobs.video.vsync = v != 0.0, "worldshadows" => knobs.video.world_shadows = v != 0.0, "charactershadows" => knobs.video.character_shadows = v != 0.0, + "shadowdistance" => { + knobs.video.shadow_distance = + v.clamp(*SHADOW_DISTANCE_RANGE.start(), *SHADOW_DISTANCE_RANGE.end()); + } // Display mode (1627) — a flag like every other checkbox here, and the reference's own // polarity: `1` is WINDOWED (the row is "Windowed Mode"). `video::apply_window_mode` // watches the value and pushes it to the window; nothing else reads it. @@ -1374,7 +1387,7 @@ fn sync_cvars( .collect(), ); let flag = |b: bool| if b { "1" } else { "0" }.to_string(); - let session: [(&str, String); 46] = [ + let session: [(&str, String); 47] = [ ("MasterVolume", sound.master.to_string()), ("SoundVolume", sound.sfx.to_string()), ("MusicVolume", sound.music.to_string()), @@ -1422,6 +1435,7 @@ fn sync_cvars( ("gxVSync", flag(video.vsync)), ("worldShadows", flag(video.world_shadows)), ("characterShadows", flag(video.character_shadows)), + ("shadowDistance", video.shadow_distance.to_string()), // The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state. ( "gxWindow", diff --git a/crates/benilla-app/src/shadow_core.rs b/crates/benilla-app/src/shadow_core.rs index e2fb1bb1..ec677271 100644 --- a/crates/benilla-app/src/shadow_core.rs +++ b/crates/benilla-app/src/shadow_core.rs @@ -44,21 +44,27 @@ use benilla_assets::materials::WowModelMaterial; use benilla_formats::ModelBlend; use benilla_world::billboard::BillboardCard; use benilla_world::interact::PickMesh; -use benilla_world::lighting::WowLighting; +use benilla_world::lighting::{ShadowDistance, WowLighting}; use benilla_world::model_render::{ModelKind, ModelPart, ShadowOccluder}; use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin}; use benilla_world::view::WorldCamera; use crate::char_select::ClientState; +use crate::video::VideoConfig; /// Layer 31 is deliberately private to this feature. Bevy supports 32 render layers; the normal /// world is layer 0 and the UI/portrait layers occupy the low numbered slots. pub(crate) const PLAYER_SHADOW_LAYER: usize = 31; -/// How far from the camera the shadow map RESOLVES (the single cascade's `maximum_distance`). -/// Collection reaches farther than this: a caster's own body may stand outside the cascade -/// while its shadow lands inside it — so an out-of-frustum tree still darkens ground in view. -const CASTER_RANGE: f32 = 80.0; +/// Shipped realtime-shadow render distance in yards — the `shadowDistance` default. How far from the +/// camera the shadow map RESOLVES (the single cascade's `maximum_distance`). Collection reaches +/// farther than this (a caster's body may stand outside the cascade while its shadow lands inside). +pub(crate) const DEFAULT_SHADOW_DISTANCE: f32 = 80.0; + +/// The `shadowDistance` slider range (yd). Min keeps a useful near shadow; max is bounded because +/// the fixed 4096 shadow map spreads thinner (softer) over a larger area and the caster-collection +/// cost grows with distance. +pub(crate) const SHADOW_DISTANCE_RANGE: std::ops::RangeInclusive = 40.0..=200.0; /// How far the camera may drift before a cached caster-collection pass must refresh. The collection /// reach carries this as margin so coverage holds everywhere between refreshes. @@ -98,8 +104,8 @@ fn shadow_reach_extension(sun_travel: Vec3) -> f32 { /// The collection law for a lane that can contribute a TALL caster (a doodad/WMO exile, or the whole /// static world): resolve range + rebuild margin + the sun-dependent shadow reach. -pub(crate) fn static_collection_reach(sun_travel: Vec3) -> f32 { - CASTER_RANGE + STATIC_REBUILD_STEP + shadow_reach_extension(sun_travel) +pub(crate) fn static_collection_reach(sun_travel: Vec3, distance: f32) -> f32 { + distance + STATIC_REBUILD_STEP + shadow_reach_extension(sun_travel) } /// `WOW_SHADOW_TRACE=1` — log the caster population when it CHANGES, to attribute a vanished shadow @@ -190,6 +196,10 @@ struct ShadowRigState { sun_written: Option, /// The invisible proxy material shared by every lane's SOLID caster. material: Option>, + /// The cascade's currently-applied `maximum_distance` (the `shadowDistance` slider). The + /// `CascadeShadowConfig` is only re-inserted when this changes — rebuilding it every frame would + /// re-fit the cascade and defeat the texel snap (crawling every shadow edge). + cascade_distance: Option, } pub(crate) struct ShadowCorePlugin; @@ -234,10 +244,14 @@ impl Plugin for ShadowCorePlugin { #[allow(clippy::too_many_arguments)] fn manage_rig( state: Res>, + video: Res, lighting: Res, mut demand: ResMut, mut frame: ResMut, mut rig: ResMut, + // MONKEY (distance slider): bridge the app-side `shadowDistance` to benilla-world so + // `global_light` can pack it for the receivers' edge fade (which must fade at THIS distance). + mut shadow_distance_out: ResMut, mut commands: Commands, mut materials: ResMut>, mut cameras: Query< @@ -248,6 +262,12 @@ fn manage_rig( cameras_for_position: Query<&GlobalTransform, With>, ) { let in_world = *state.get() == ClientState::InWorld; + // The `shadowDistance` slider (already clamped by the cvar). Publish it to benilla-world every + // frame so the receiver edge fade tracks it. + let distance = video.shadow_distance; + if shadow_distance_out.0 != distance { + shadow_distance_out.0 = distance; + } // Read the demand the lanes accumulated LAST frame, then reset so they re-accumulate this frame // (they run after us, in ShadowSet::Lanes). One-frame lag on rig spawn — imperceptible. let wanted = demand.0 && in_world; @@ -296,7 +316,7 @@ fn manage_rig( CascadeShadowConfigBuilder { num_cascades: 1, minimum_distance: 0.1, - maximum_distance: CASTER_RANGE, + maximum_distance: distance, ..default() } .build(), @@ -308,6 +328,23 @@ fn manage_rig( .id(); rig.sun = Some(sun); rig.sun_written = None; + rig.cascade_distance = Some(distance); + } + // Re-fit the cascade only when the slider changed (not every frame — that would defeat the + // texel snap and crawl every shadow edge). + if rig.cascade_distance != Some(distance) { + if let Some(sun) = rig.sun { + commands.entity(sun).insert( + CascadeShadowConfigBuilder { + num_cascades: 1, + minimum_distance: 0.1, + maximum_distance: distance, + ..default() + } + .build(), + ); + } + rig.cascade_distance = Some(distance); } for (entity, layers, marker) in &mut cameras { @@ -336,8 +373,8 @@ fn manage_rig( // MONKEY (moving sun): aim at the VISIBLE celestial sun (rises/sets), clamped in elevation. let sun_direction = shadow_sun_travel(lighting.celestial_dir()); - let entity_reach = CASTER_RANGE + STATIC_REBUILD_STEP; - let tall_reach = static_collection_reach(sun_direction); + let entity_reach = distance + STATIC_REBUILD_STEP; + let tall_reach = static_collection_reach(sun_direction, distance); if let Some(sun) = rig.sun { // Only the rotation matters; QUANTISED to hold the cascade texel snap (see SUN_SNAP_RADIANS). @@ -569,7 +606,7 @@ fn append_skinned( mod tests { use super::{ append_triangles, casts_realtime_shadow, shadow_reach_extension, shadow_sun_travel, - static_collection_reach, sun_snap_due, CASTER_RANGE, MAX_CASTER_HEIGHT, + static_collection_reach, sun_snap_due, DEFAULT_SHADOW_DISTANCE, MAX_CASTER_HEIGHT, MAX_SHADOW_SUN_ELEVATION, MIN_SHADOW_SUN_ELEVATION, SHADOW_REACH_CAP, STATIC_REBUILD_STEP, SUN_SNAP_RADIANS, }; @@ -636,7 +673,10 @@ mod tests { assert!((shadow_reach_extension(diagonal) - MAX_CASTER_HEIGHT).abs() < 1e-3); assert_eq!(shadow_reach_extension(Vec3::NEG_Y), 0.0); assert_eq!(shadow_reach_extension(Vec3::NEG_Z), SHADOW_REACH_CAP); - assert!(static_collection_reach(Vec3::NEG_Y) >= CASTER_RANGE + STATIC_REBUILD_STEP); + assert!( + static_collection_reach(Vec3::NEG_Y, DEFAULT_SHADOW_DISTANCE) + >= DEFAULT_SHADOW_DISTANCE + STATIC_REBUILD_STEP + ); } #[test] diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs index 380428bc..80fe8feb 100644 --- a/crates/benilla-app/src/ui_script/options_tests.rs +++ b/crates/benilla-app/src/ui_script/options_tests.rs @@ -2186,6 +2186,7 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() { ), ("BENILLA_TOOLTIP_CHARACTER_SHADOWS", "GraphicsRowCharacterShadows"), ("BENILLA_TOOLTIP_WORLD_SHADOWS", "GraphicsRowWorldShadows"), + ("BENILLA_TOOLTIP_SHADOW_DISTANCE", "GraphicsRowShadowDistance"), ]; if let Some((_, want_row)) = BENILLA_OWNED.iter().find(|(k, _)| *k == key) { assert_eq!(row, *want_row, "{row}: not this row's string"); @@ -2231,8 +2232,9 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() { // Enable Sound in Background (1847), the Audio page's fifth checkbox and the one row on that // page the reference never made settable. // MONKEY (world shadows): +1 for splitting the old single shadow row into Real Character - // Shadows + World Shadows (both benilla-owned keys, carved out in BENILLA_OWNED above). - assert_eq!(checked, 60, "every tipped row carries a live key"); + // Shadows + World Shadows, +1 for the Shadow Distance slider (all benilla-owned keys, carved + // out in BENILLA_OWNED above). + assert_eq!(checked, 61, "every tipped row carries a live key"); assert_eq!( untipped, vec![ diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs index 41c36c1a..a92f0346 100644 --- a/crates/benilla-app/src/video.rs +++ b/crates/benilla-app/src/video.rs @@ -212,7 +212,8 @@ pub(crate) fn boot_windowed_size() -> UVec2 { /// since 0294): the window literal resolves env-then-file for itself, `load_config` applies the /// file to this resource at `Startup`, and because both read the same key the reconcile is a no-op /// rather than a mode change one frame into the run. -#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug)] +// NB: no `Eq` — `shadow_distance` is an f32 (only `PartialEq` is needed, for `!=` change detection). +#[derive(Resource, Clone, Copy, PartialEq, Debug)] pub(crate) struct VideoConfig { pub(crate) vsync: bool, /// Whether the STATIC WORLD (trees, buildings, foliage) casts realtime shadows and baked MCSH @@ -222,6 +223,9 @@ pub(crate) struct VideoConfig { /// Whether CHARACTERS (players, NPCs, creatures, mounts) cast realtime silhouettes instead of /// the legacy oval blob. Independent of [`Self::world_shadows`]. pub(crate) character_shadows: bool, + /// Realtime-shadow render distance in yards (the `shadowDistance` slider) — the shadow-map + /// cascade range + caster reach. Clamped to `shadow_core::SHADOW_DISTANCE_RANGE`. + pub(crate) shadow_distance: f32, pub(crate) display: DisplayMode, /// The windowed size, `gxResolution`. Kept while fullscreen so leaving it can restore it. pub(crate) windowed: UVec2, @@ -233,6 +237,7 @@ impl Default for VideoConfig { vsync: !novsync_env(), world_shadows: false, character_shadows: false, + shadow_distance: crate::shadow_core::DEFAULT_SHADOW_DISTANCE, display: if windowed_env() { DisplayMode::Windowed } else { @@ -545,6 +550,7 @@ mod tests { vsync: true, world_shadows: false, character_shadows: false, + shadow_distance: 80.0, display: DisplayMode::Fullscreen, windowed: UVec2::new(1024, 768), }) diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl index 717c694c..2cf73e30 100644 --- a/crates/benilla-assets/src/shaders/terrain.wgsl +++ b/crates/benilla-assets/src/shaders/terrain.wgsl @@ -41,9 +41,9 @@ // A fully covered character shadow retains 45% of the authored terrain colour. const SHADOW_SUN_FLOOR: f32 = 0.45; -// MONKEY (edge fade): the realtime shadow lightens toward the cascade's max distance so it fades in -// rather than popping at the resolve boundary. SHADOW_RANGE must match `shadow_core::CASTER_RANGE`. -const SHADOW_RANGE: f32 = 80.0; +// MONKEY (edge fade): the realtime shadow lightens over the last SHADOW_EDGE_BAND yards of the +// cascade's max distance so it fades in rather than popping at the resolve boundary. The distance +// is the `shadowDistance` slider, read live from `_wmo_fog[1].z` (packed by build_light_data). const SHADOW_EDGE_BAND: f32 = 14.0; // Per-tile Vec4 uniforms packed into ONE buffer (binding 106) — the field order here MUST match the @@ -323,7 +323,8 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 { // lighten `world_shadow` toward 1.0 (no shadow). `sun_shadow_strength` is reused for the MCSH // fade-back below. let cam_dist = distance(in.world_position.xyz, view.world_position.xyz); - let edge_fade = smoothstep(SHADOW_RANGE - SHADOW_EDGE_BAND, SHADOW_RANGE, cam_dist); + let shadow_range = wow_light._wmo_fog[1].z; // the `shadowDistance` slider (yd) + let edge_fade = smoothstep(shadow_range - SHADOW_EDGE_BAND, shadow_range, cam_dist); let sun_shadow_strength = wow_light.fog_params.z; world_shadow = 1.0 - (1.0 - world_shadow) * sun_shadow_strength * (1.0 - edge_fade); let primary = in.primary; diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl index 8036eed9..09fc9543 100644 --- a/crates/benilla-assets/src/shaders/wow_model.wgsl +++ b/crates/benilla-assets/src/shaders/wow_model.wgsl @@ -115,9 +115,9 @@ struct ModelParams { // A fully covered character shadow retains 45% of the authored model lighting. const SHADOW_SUN_FLOOR: f32 = 0.45; -// MONKEY (edge fade): the realtime shadow lightens toward the cascade's max distance so it fades in -// rather than popping at the resolve boundary. SHADOW_RANGE must match `shadow_core::CASTER_RANGE`. -const SHADOW_RANGE: f32 = 80.0; +// MONKEY (edge fade): the realtime shadow lightens over the last SHADOW_EDGE_BAND yards of the +// cascade's max distance so it fades in rather than popping. The distance is the `shadowDistance` +// slider, read live from `wmo_fog_params.z` (packed by build_light_data). const SHADOW_EDGE_BAND: f32 = 14.0; // The shared global light (lighting::global_light): ONE storage buffer every material reads, updated @@ -1025,7 +1025,8 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut { // vanish at night. No effect when unshadowed (player_shadow already 1.0) — keeps the // `worldShadows 0` path byte-identical. let shadow_cam_dist = distance(in.world_position.xyz, view.world_position.xyz); - let shadow_edge_fade = smoothstep(SHADOW_RANGE - SHADOW_EDGE_BAND, SHADOW_RANGE, shadow_cam_dist); + let shadow_range = wow_light.wmo_fog_params.z; // the `shadowDistance` slider (yd) + let shadow_edge_fade = smoothstep(shadow_range - SHADOW_EDGE_BAND, shadow_range, shadow_cam_dist); player_shadow = 1.0 - (1.0 - player_shadow) * wow_light.fog_params.z * (1.0 - shadow_edge_fade); // The realtime map blocks only the directional sun. Preserve the authored ambient/probe // contribution instead of multiplying the whole lighting result; the latter makes interiors, diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs index e97fa583..9bfa6f1d 100644 --- a/crates/benilla-world/src/lighting/global_light.rs +++ b/crates/benilla-world/src/lighting/global_light.rs @@ -184,6 +184,19 @@ pub struct LightRooms(pub(crate) crate::wmo_portal::WmoGroupVis); #[derive(Resource, Clone, Copy, Default)] pub struct WorldShadowActive(pub bool); +/// MONKEY (distance slider): the realtime-shadow render distance in yards (the `shadowDistance` +/// slider), bridged from benilla-app's shadow rig. [`build_light_data`] packs it into a free light +/// lane so the receivers (`terrain.wgsl`/`wow_model.wgsl`) fade the realtime shadow at THIS distance +/// rather than a fixed one — the fade must track the cascade's actual `maximum_distance`. +#[derive(Resource, Clone, Copy)] +pub struct ShadowDistance(pub f32); + +impl Default for ShadowDistance { + fn default() -> Self { + Self(80.0) + } +} + /// Main-world resource holding the packed light for this frame; extracted into the render world where /// [`upload_light`] writes it. Rebuilt every frame by [`build_light_data`] (cheap — one std430 pack). #[derive(Resource, Clone, Copy, ExtractResource)] @@ -211,6 +224,7 @@ pub struct SharedLightBuffer(pub Buffer); pub(super) fn register(app: &mut App) { app.init_resource::() .init_resource::() + .init_resource::() .init_resource::() .add_plugins(ExtractResourcePlugin::::default()) .add_plugins(ExtractResourcePlugin::::default()) @@ -296,6 +310,8 @@ fn build_light_data( time: Res