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