From 3c33fd711769ca0b5b52e69e1bdbf16fdb26e21d Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Thu, 3 Sep 2026 15:18:04 +0100
Subject: [PATCH] MONKEY: split shadows into independent character + world
lanes
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Rework the realtime shadow feature into two INDEPENDENT, plug-and-play
modules over one shared rig, plus alpha-tested foliage:
- character_shadow.rs (was player_shadow.rs): owns the shared rig — one
directional "sun" on private layer 31, the camera-layer plumbing, the
shadow map, the invisible proxy material — and the per-frame CHARACTER
lane. The rig lights up whenever EITHER lane is on.
- world_shadow.rs (NEW, self-contained): the WORLD lane — the retained
static_gx world (trees + buildings, cast solid) and alpha-tested foliage
(leaf canopies, one caster per leaf texture). Removing the feature is
deleting this module + its two calls + the cvar.
Two separate cvars, each on its own Options row: `characterShadows`
(players/NPCs/creatures/mounts, replacing the oval blob) and `worldShadows`
(the static world + foliage, and MCSH terrain-shadow suppression). Either
alone drives the shared sun; the per-frame entity caster admits Creature
parts under characterShadows and entity-resident environment under
worldShadows.
Alpha-tested foliage caster (leaf-shaped canopy shadows): a
CutoutShadowCasterMaterial (AlphaMode::Mask + an explicit prepass fragment,
shadow_caster_cutout_prepass.wgsl) samples the leaf sheet and discards
transparent texels in the directional-shadow pass, so a canopy casts its
leaves instead of a solid box. Grouped one caster per leaf texture;
StaticGx::collect_cutout_shadow_triangles buckets by AssetId and reuses the
foliage's own image handle so the sampler inherits the correct clamp mode.
MCSH terrain-shadow suppression now keys on the WORLD lane only (packed into
the free sh_c16.w light lane via a new WorldShadowActive resource), not on
the mere presence of a shadow sun — so character-only shadows leave the
world's baked shadows intact. blob_shadow now hides the oval on
characterShadows, not worldShadows.
Co-Authored-By: Claude Opus 4.8
---
crates/benilla-app/assets/ui/OptionsFrame.xml | 27 +-
crates/benilla-app/src/blob_shadow.rs | 14 +-
.../{player_shadow.rs => character_shadow.rs} | 395 ++++++++++++------
crates/benilla-app/src/cvars.rs | 15 +-
crates/benilla-app/src/lib.rs | 5 +-
crates/benilla-app/src/shaders.rs | 3 +-
.../shaders/shadow_caster_cutout_prepass.wgsl | 63 +++
.../src/ui_script/options_tests.rs | 5 +-
crates/benilla-app/src/video.rs | 9 +-
crates/benilla-app/src/world_shadow.rs | 164 ++++++++
.../benilla-assets/src/shaders/terrain.wgsl | 13 +-
.../src/lighting/global_light.rs | 16 +
crates/benilla-world/src/lighting/mod.rs | 2 +-
crates/benilla-world/src/static_gx/mod.rs | 2 +
crates/benilla-world/src/static_gx/shadow.rs | 164 ++++++++
15 files changed, 752 insertions(+), 145 deletions(-)
rename crates/benilla-app/src/{player_shadow.rs => character_shadow.rs} (59%)
create mode 100644 crates/benilla-app/src/shaders/shadow_caster_cutout_prepass.wgsl
create mode 100644 crates/benilla-app/src/world_shadow.rs
create mode 100644 crates/benilla-world/src/static_gx/shadow.rs
diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 39320152..724c212b 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -677,10 +677,15 @@
.. "There is no exclusive fullscreen mode. WoW itself removed one in 8.0.1, and "
.. "modern Classic offers these same two."
+ BENILLA_TOOLTIP_CHARACTER_SHADOWS =
+ "Renders real dynamic silhouettes for players, NPCs, creatures, and mounts, replacing "
+ .. "the legacy oval blob. Matches Wrath's character-only shadow tier. This uses "
+ .. "additional GPU time and memory."
+
BENILLA_TOOLTIP_WORLD_SHADOWS =
- "Renders real dynamic silhouettes for players, NPCs, creatures, and mounts. Terrain, "
- .. "buildings, and trees keep their authored shading, matching Wrath's character-only "
- .. "shadow tier. This uses additional GPU time and memory."
+ "Casts real dynamic shadows from the static world (trees, buildings, and foliage) "
+ .. "following the sun, and switches off the baked terrain shadows they replace. "
+ .. "Independent of Real Character Shadows. This uses additional GPU time and memory."
OPTIONS_PAGE_ROWS = {
Controls = {
@@ -694,7 +699,7 @@
},
Graphics = {
"RowUiScale", "RowFarclip", "RowWorldDetail",
- "RowDisplayMode", "RowVerticalSync", "RowWorldShadows", "RowRenderScale",
+ "RowDisplayMode", "RowVerticalSync", "RowCharacterShadows", "RowWorldShadows", "RowRenderScale",
},
Nameplates = {
"RowEnemyPlates", "RowFriendlyPlates",
@@ -2522,13 +2527,23 @@
"OPTION_TOOLTIP_VERTICAL_SYNC")
-
+
- OptionsRow_OnLoad(self, "worldShadows", "Real Character Shadows",
+ OptionsRow_OnLoad(self, "characterShadows", "Real Character Shadows",
+ "BENILLA_TOOLTIP_CHARACTER_SHADOWS")
+
+
+
+
+
+
+
+
+ OptionsRow_OnLoad(self, "worldShadows", "World Shadows",
"BENILLA_TOOLTIP_WORLD_SHADOWS")
diff --git a/crates/benilla-app/src/blob_shadow.rs b/crates/benilla-app/src/blob_shadow.rs
index 953f15d8..c511e27f 100644
--- a/crates/benilla-app/src/blob_shadow.rs
+++ b/crates/benilla-app/src/blob_shadow.rs
@@ -220,12 +220,14 @@ fn update_shadows(
// report, answerable from a log instead of a debugger.
mut census_at: Local,
) {
- // `worldShadows 1`: the realtime shadow-map path owns unit shadows — every record hides so a
- // unit never wears the oval underneath its cast shadow. The records themselves stay (spawned
- // by `sync_shadows` as usual) and `hide` drops each cache key, so flipping the cvar back
- // rebuilds every projection on the next frame. With the cvar off — the shipped default —
- // this gate is a single false branch and the lane below is the untouched reference path.
- if video.world_shadows {
+ // `characterShadows 1`: the realtime shadow-map path owns UNIT shadows — every record hides so a
+ // unit never wears the oval underneath its cast silhouette. (This keys on the CHARACTER lane, not
+ // the world lane: the oval is a character's shadow, so `worldShadows` alone must leave it be.)
+ // The records themselves stay (spawned by `sync_shadows` as usual) and `hide` drops each cache
+ // key, so flipping the cvar back rebuilds every projection on the next frame. With the cvar off —
+ // the shipped default — this gate is a single false branch and the lane below is the untouched
+ // reference path.
+ if video.character_shadows {
for (_, mut key, mut verts) in &mut shadows {
if key.shown || !verts.0.is_empty() {
hide(&mut key, &mut verts);
diff --git a/crates/benilla-app/src/player_shadow.rs b/crates/benilla-app/src/character_shadow.rs
similarity index 59%
rename from crates/benilla-app/src/player_shadow.rs
rename to crates/benilla-app/src/character_shadow.rs
index 01bcbf23..1eb0f3b9 100644
--- a/crates/benilla-app/src/player_shadow.rs
+++ b/crates/benilla-app/src/character_shadow.rs
@@ -1,19 +1,32 @@
-//! Isolated real shadow-map support for the world.
+//! Real shadow-map support for benilla, split into two INDEPENDENT lanes over ONE shared rig.
//!
-//! The old unit shadow is a projected oval. This path deliberately does not put a directional
-//! light on the normal world layer: that would make every existing custom WoW material enter
-//! Bevy's shadow-prepass path, which is the source of the pipeline validation/corruption failure
-//! seen during the first experiment. Instead, a private layer contains one CPU-built copy of the
-//! current world model triangles. Only that copy casts; the normal world shaders only receive the
-//! resulting shadow lookup when the option is enabled.
+//! This module owns the shared **rig** — a single directional light (the "sun") on a private render
+//! layer, that layer's membership on the world camera, and the `DirectionalLightShadowMap` — plus
+//! the **character lane**: the per-frame CPU-built caster carrying entity geometry (players, NPCs,
+//! creatures, mounts). The sibling [`super::world_shadow`] module owns the **world lane** (the
+//! static `static_gx` trees/buildings + alpha-tested foliage). The two lanes are gated by SEPARATE
+//! cvars — `characterShadows` and `worldShadows` — and either one alone lights up the shared rig.
+//!
+//! The rig deliberately does NOT put a directional light on the normal world layer: that would make
+//! every existing custom WoW material enter Bevy's shadow-prepass path, the source of the pipeline
+//! validation/corruption failure seen during the first experiment. Instead a private layer holds
+//! CPU-built copies of the caster triangles; only those copies cast, and the normal world shaders
+//! merely RECEIVE the resulting shadow lookup.
+//!
+//! Why one shared light and not one per lane: the custom receiver in `terrain.wgsl`/`wow_model.wgsl`
+//! samples a SINGLE directional light (it assigns, not accumulates, over the light loop). Two lights
+//! would double the shadow-pass cost and the last one would win the receiver. So the lanes share the
+//! rig and each simply contributes its own caster geometry into the one map.
-use bevy::asset::RenderAssetUsages;
+use bevy::asset::{AssetId, RenderAssetUsages};
use bevy::camera::visibility::{NoFrustumCulling, RenderLayers};
+use bevy::image::Image;
use bevy::light::{
CascadeShadowConfigBuilder, DirectionalLight, DirectionalLightShadowMap, NotShadowReceiver,
};
use bevy::mesh::{Indices, PrimitiveTopology};
use bevy::pbr::{Material, MaterialPipeline, MaterialPlugin, MeshMaterial3d};
+use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use bevy::reflect::TypePath;
use bevy::render::render_resource::{
@@ -26,13 +39,14 @@ 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::{WorldShadowActive, 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;
+use crate::world_shadow;
/// 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.
@@ -47,7 +61,7 @@ const CASTER_RANGE: f32 = 80.0;
/// How far the camera may drift before a caster-collection pass must refresh. The collection
/// reach carries this as margin so coverage holds everywhere between refreshes.
-const STATIC_REBUILD_STEP: f32 = 16.0;
+pub(crate) const STATIC_REBUILD_STEP: f32 = 16.0;
/// The tallest COMMON caster the reach law budgets for (the big Elwynn/Duskwood tree class,
/// in world units). Not a clamp on what casts — only on how far past the resolve range the
@@ -75,8 +89,8 @@ fn shadow_reach_extension(sun_travel: Vec3) -> f32 {
}
/// The one collection law for a lane that can contribute a TALL caster (a doodad/WMO entity
-/// exile, were the character-only tier ever to admit one): resolve range + rebuild margin +
-/// the sun-dependent shadow reach.
+/// exile, or the whole static world): resolve range + rebuild margin + the sun-dependent shadow
+/// reach.
fn static_collection_reach(sun_travel: Vec3) -> f32 {
CASTER_RANGE + STATIC_REBUILD_STEP + shadow_reach_extension(sun_travel)
}
@@ -86,7 +100,7 @@ fn static_collection_reach(sun_travel: Vec3) -> f32 {
/// camera rotation means the entity lane is still being view-culled somewhere; a static
/// rebuild logged at the same moment means the cached half swapped content; neither changing
/// while the shadow still pops on screen exonerates the caster and indicts the map/receiver.
-fn shadow_trace() -> bool {
+pub(crate) fn shadow_trace() -> bool {
static ON: std::sync::OnceLock = std::sync::OnceLock::new();
*ON.get_or_init(|| std::env::var("WOW_SHADOW_TRACE").is_ok_and(|v| v != "0"))
}
@@ -119,7 +133,7 @@ fn sun_snap_due(held: Option, live: Vec3) -> bool {
}
#[derive(Component)]
-struct WorldShadowCaster;
+pub(crate) struct WorldShadowCaster;
#[derive(Component)]
struct WorldShadowSun;
@@ -129,32 +143,44 @@ struct WorldShadowCameraLayer {
had_layers: bool,
}
+/// Shared state for both shadow lanes. The rig + character fields are private to this module; the
+/// world-lane fields are `pub(crate)` because [`super::world_shadow`] owns their lifecycle.
#[derive(Resource, Default)]
-struct WorldShadowRuntime {
- /// The PER-FRAME caster: entity parts (animated rigs, creatures) collected fresh every
- /// frame — the character-only tier's whole caster population.
- dynamic_caster: Option,
+pub(crate) struct ShadowRuntime {
+ // --- shared rig ---
sun: Option,
- /// The direction the sun entity was LAST GIVEN — the held shadow basis. The live
- /// direction is compared against this each frame and written through only past
- /// [`SUN_SNAP_RADIANS`] (see there for why); `None` until the first write, and again
- /// after teardown, so spawn and re-enable always aim the light immediately.
+ /// The direction the sun entity was LAST GIVEN — the held shadow basis. The live direction is
+ /// compared against this each frame and written through only past [`SUN_SNAP_RADIANS`] (see
+ /// there for why); `None` until the first write, and again after teardown, so spawn and
+ /// re-enable always aim the light immediately.
sun_written: Option,
- dynamic_mesh: Option>,
+ /// The invisible proxy material shared by BOTH lanes' solid casters (opaque to the shadow pass,
+ /// discarded in the forward pass).
material: Option>,
+ // --- character lane (per-frame entity caster) ---
+ /// The PER-FRAME caster: entity parts (animated rigs, creatures, and — when `worldShadows` is
+ /// on — entity-resident environment doodads/gameobjects) collected fresh every frame.
+ dynamic_caster: Option,
+ dynamic_mesh: Option>,
/// [`shadow_trace`]'s change detector: (dynamic tris, admitted parts, rejected parts).
- /// Triangle counts only change when the SET changes (animation moves vertices, not
- /// counts), so any change here is a population event worth one log line.
traced: (u32, u32, u32),
+ // --- world lane (owned by `super::world_shadow`) ---
+ /// The CACHED static caster — the retained `static_gx` world (trees + buildings), rebuilt only
+ /// on camera drift.
+ pub(crate) static_caster: Option,
+ pub(crate) static_mesh: Option>,
+ pub(crate) static_rebuilt_at: Option,
+ /// The alpha-tested foliage casters — one per distinct leaf texture, keyed by its `AssetId`.
+ pub(crate) static_cutout: HashMap, CutoutCaster>,
}
-pub(crate) struct WorldShadowPlugin;
+pub(crate) struct ShadowPlugin;
/// Opaque to Bevy's shadow pass, but discarded by the normal forward pass. This lets the
/// private-layer proxy participate in the real shadow map without drawing a second copy of the
-/// world in the main camera.
+/// world in the main camera. Shared by both lanes' SOLID casters.
#[derive(Asset, AsBindGroup, TypePath, Debug, Clone, Default)]
-struct ShadowCasterMaterial {}
+pub(crate) struct ShadowCasterMaterial {}
impl Material for ShadowCasterMaterial {
fn fragment_shader() -> ShaderRef {
@@ -174,23 +200,86 @@ impl Material for ShadowCasterMaterial {
}
}
-impl Plugin for WorldShadowPlugin {
- fn build(&self, app: &mut App) {
- app.insert_resource(DirectionalLightShadowMap { size: 2048 })
- .add_plugins(MaterialPlugin::::default())
- .init_resource::()
- .add_systems(Last, update_world_shadow);
+/// The alpha-tested twin of [`ShadowCasterMaterial`]: the same invisible-in-forward proxy, but its
+/// prepass fragment samples a leaf sheet and discards transparent texels so cutout FOLIAGE (tree
+/// canopies) casts a leaf-shaped silhouette instead of the solid box a positions-only proxy throws.
+/// One instance per distinct leaf texture — a merged caster mesh carries only one sheet. Used only
+/// by the WORLD lane ([`super::world_shadow`]).
+///
+/// `AlphaMode::Mask` sets Bevy's `MAY_DISCARD` pipeline key, and the EXPLICIT (non-`Default`)
+/// `prepass_fragment_shader` is what makes the otherwise depth-only directional-light SHADOW pass
+/// run a fragment at all — together they are the exact pair Bevy's prepass specializer requires to
+/// alpha-test in the shadow map (`prepass/mod.rs`: `MAY_DISCARD && get_shader(PrepassFragmentShader)`).
+#[derive(Asset, AsBindGroup, TypePath, Debug, Clone)]
+pub(crate) struct CutoutShadowCasterMaterial {
+ /// The leaf sheet, bound at material binding 0/1. Reusing the SAME image handle the forward
+ /// pass draws makes the sampler inherit that image's clamp/repeat address mode, so the shadow
+ /// silhouette matches the visible foliage (a cutout card's out-of-range UVs must clamp, not
+ /// wrap, or the transparent margin folds back into the opaque middle and it casts solid).
+ #[texture(0)]
+ #[sampler(1)]
+ pub(crate) leaf: Handle,
+}
+
+impl Material for CutoutShadowCasterMaterial {
+ fn fragment_shader() -> ShaderRef {
+ // Forward pass: still fully invisible — the shared proxy fragment discards every fragment.
+ "embedded://benilla_app/shaders/shadow_caster.wgsl".into()
+ }
+
+ fn prepass_fragment_shader() -> ShaderRef {
+ // EXPLICIT → the shadow pass runs this fragment for the MAY_DISCARD material and alpha-tests.
+ "embedded://benilla_app/shaders/shadow_caster_cutout_prepass.wgsl".into()
+ }
+
+ fn alpha_mode(&self) -> AlphaMode {
+ AlphaMode::Mask(benilla_assets::materials::VANILLA_ALPHA_KEY_REF)
+ }
+
+ fn specialize(
+ _pipeline: &MaterialPipeline,
+ descriptor: &mut RenderPipelineDescriptor,
+ _layout: &bevy::mesh::MeshVertexBufferLayoutRef,
+ _key: bevy::pbr::MaterialPipelineKey,
+ ) -> Result<(), SpecializedMeshPipelineError> {
+ // Two-sided like the solid caster — a canopy card seen edge-on must still occlude. Applies
+ // to the shadow (prepass) pipeline too: Bevy runs `specialize` for the prepass specializer.
+ descriptor.primitive.cull_mode = None;
+ Ok(())
}
}
-fn update_world_shadow(
+/// One live alpha-tested foliage caster: its layer-31 entity, its per-texture caster mesh, and the
+/// [`CutoutShadowCasterMaterial`] carrying that one leaf sheet. Keyed by the leaf texture's
+/// `AssetId` in [`ShadowRuntime::static_cutout`] so it persists across rebuilds.
+pub(crate) struct CutoutCaster {
+ pub(crate) entity: Entity,
+ pub(crate) mesh: Handle,
+ pub(crate) material: Handle,
+}
+
+impl Plugin for ShadowPlugin {
+ fn build(&self, app: &mut App) {
+ app.insert_resource(DirectionalLightShadowMap { size: 4096 }) // MONKEY: crisper shadow edges
+ .add_plugins(MaterialPlugin::::default())
+ // MONKEY (world shadows): the alpha-tested foliage caster material (leaf-shaped canopy shadows).
+ .add_plugins(MaterialPlugin::::default())
+ .init_resource::()
+ .add_systems(Last, update_shadows);
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+fn update_shadows(
state: Res>,
video: Res,
lighting: Res,
- mut runtime: ResMut,
+ mut runtime: ResMut,
mut commands: Commands,
mut meshes: ResMut>,
mut materials: ResMut>,
+ // MONKEY (world shadows): the per-leaf-texture alpha-tested foliage caster materials.
+ mut cutout_materials: ResMut>,
parts: Query<
(
&PickMesh,
@@ -214,9 +303,24 @@ fn update_world_shadow(
>,
mut suns: Query<&mut Transform, With>,
cameras_for_position: Query<&GlobalTransform, With>,
+ // MONKEY (world shadows): the retained static world — the source of the cached static caster.
+ // `Option` because the resource exists only when the retained pass is armed (`WOW_STATIC_GX`).
+ gx: Option>,
+ // MONKEY (world shadows): the terrain MCSH-suppression flag — driven by the WORLD lane only.
+ mut world_active: ResMut,
) {
- let wanted = video.world_shadows && *state.get() == ClientState::InWorld;
- if !wanted {
+ let in_world = *state.get() == ClientState::InWorld;
+ let world_on = video.world_shadows && in_world;
+ let char_on = video.character_shadows && in_world;
+
+ // Baked MCSH terrain shadows switch off ONLY for the world lane (a character-only shadow sun
+ // must leave the world's baked shadows intact). `terrain.wgsl` reads this via `sh_c16.w`.
+ if world_active.0 != world_on {
+ world_active.0 = world_on;
+ }
+
+ if !world_on && !char_on {
+ // Both lanes dark: tear the whole rig down.
if let Some(entity) = runtime.dynamic_caster.take() {
commands.entity(entity).despawn();
}
@@ -229,6 +333,7 @@ fn update_world_shadow(
if let Some(handle) = runtime.dynamic_mesh.take() {
meshes.remove(handle.id());
}
+ world_shadow::teardown(&mut runtime, &mut commands, &mut meshes, &mut cutout_materials);
if let Some(handle) = runtime.material.take() {
materials.remove(handle.id());
}
@@ -246,27 +351,11 @@ fn update_world_shadow(
return;
}
- // The proxies are opaque to the shadow pass and their custom forward fragment discards
- // every fragment. They have no texture lookup or custom WoW shader, so their shadow
- // pipeline cannot perturb the login/character/world material pipelines.
- if runtime.dynamic_caster.is_none() {
- let dynamic_mesh = meshes.add(empty_shadow_mesh());
- let material = materials.add(ShadowCasterMaterial {});
- let spawn_caster = |commands: &mut Commands, mesh: &Handle| {
- commands
- .spawn((
- Mesh3d(mesh.clone()),
- MeshMaterial3d(material.clone()),
- Transform::IDENTITY,
- Visibility::Visible,
- RenderLayers::layer(PLAYER_SHADOW_LAYER),
- NoFrustumCulling,
- NotShadowReceiver,
- WorldShadowCaster,
- ))
- .id()
- };
- let dynamic_caster = spawn_caster(&mut commands, &dynamic_mesh);
+ // --- Shared rig: the proxy material + the sun, alive whenever EITHER lane is on ---
+ if runtime.material.is_none() {
+ runtime.material = Some(materials.add(ShadowCasterMaterial {}));
+ }
+ if runtime.sun.is_none() {
let sun = commands
.spawn((
DirectionalLight {
@@ -276,18 +365,14 @@ fn update_world_shadow(
shadows_enabled: true,
// Keep enough separation from the receiver to avoid shadow-map acne on WMO
// floors, while staying small enough that the silhouette remains attached.
- // The old 0.003/0.15 pair was below the useful texel-scale bias for Stormwind
- // and let the floor shadow itself, producing the broken dark patches.
shadow_depth_bias: 0.02,
shadow_normal_bias: 0.8,
..default()
},
CascadeShadowConfigBuilder {
- // This is a short-range shadow feature, not a whole-map sun-shadow system.
- // Independent cascade grids were showing up as square light patches on the
- // cobbles when the custom receiver sampled their transition. With ONE
- // cascade, Bevy ignores `first_cascade_far_bound`/`overlap_proportion` and
- // the single 2048 map spans the full `maximum_distance`.
+ // A short-range shadow feature, not a whole-map sun-shadow system. With ONE
+ // cascade, Bevy ignores `first_cascade_far_bound`/`overlap_proportion` and the
+ // single map spans the full `maximum_distance`.
num_cascades: 1,
minimum_distance: 0.1,
maximum_distance: CASTER_RANGE,
@@ -300,10 +385,8 @@ fn update_world_shadow(
WorldShadowSun,
))
.id();
- runtime.dynamic_caster = Some(dynamic_caster);
runtime.sun = Some(sun);
- runtime.dynamic_mesh = Some(dynamic_mesh);
- runtime.material = Some(material);
+ runtime.sun_written = None; // aim it on the first frame
}
for (entity, layers, marker) in &mut cameras {
@@ -335,25 +418,37 @@ fn update_world_shadow(
} else {
Vec3::NEG_Z
};
- // The two gating radii: short casters (creatures/gameobjects — at most a body length
- // tall) stop mattering just past the resolve range; a tall-caster lane (doodads/WMO)
- // would carry the sun-dependent shadow reach on top, so a tree whose shadow lands in
- // view casts before its trunk enters the resolve range. The character-only tier never
- // admits that lane (`casts_realtime_shadow`), but `collect_world_geometry`'s signature
- // still takes both reaches.
+ // The two gating radii: short casters (creatures/gameobjects) stop mattering just past the
+ // resolve range; a tall-caster lane (doodads/WMO/static world) carries the sun-dependent
+ // shadow reach on top, so a tree whose shadow lands in view casts before its trunk enters the
+ // resolve range.
let entity_reach = CASTER_RANGE + STATIC_REBUILD_STEP;
let tall_reach = static_collection_reach(sun_direction);
- // Animated rigs re-skin every frame, and entity doodads, creatures and mounts spawn and
- // despawn under entity lifecycles this module cannot cheaply observe — the whole caster
- // population is collected fresh every frame.
+ // --- CHARACTER lane (+ entity-resident environment when the world lane is on) ---
+ // The per-frame entity caster always exists past the early-out; the ADMIT filter decides what
+ // rides it: Creature parts when `characterShadows`, entity-resident doodads/gameobjects when
+ // `worldShadows`. Animated rigs re-skin every frame, and entity content spawns/despawns under
+ // lifecycles this module cannot cheaply observe — so it is collected fresh every frame.
+ if runtime.dynamic_caster.is_none() {
+ let mesh = meshes.add(empty_shadow_mesh());
+ let material = runtime
+ .material
+ .clone()
+ .expect("rig material ensured above");
+ let entity = spawn_solid_caster(&mut commands, mesh.clone(), material);
+ runtime.dynamic_caster = Some(entity);
+ runtime.dynamic_mesh = Some(mesh);
+ }
if let Some(handle) = runtime.dynamic_mesh.clone() {
if let Some(mesh) = meshes.get_mut(&handle) {
let (mut positions, mut indices) = take_mesh_buffers(mesh);
- let (admitted, rejected) = collect_world_geometry(
+ let (admitted, rejected) = collect_entity_geometry(
&parts,
&rigs,
&palettes,
+ char_on,
+ world_on,
light_position,
entity_reach,
tall_reach,
@@ -375,13 +470,27 @@ fn update_world_shadow(
}
}
+ // --- WORLD lane: the cached static world (trees + buildings) + alpha-tested foliage ---
+ if world_on {
+ let solid = runtime.material.clone();
+ if let (Some(gx), Some(solid)) = (gx.as_ref(), solid) {
+ world_shadow::update(
+ &mut runtime,
+ &mut commands,
+ &mut meshes,
+ &solid,
+ &mut cutout_materials,
+ gx,
+ light_position,
+ tall_reach,
+ );
+ }
+ } else {
+ world_shadow::teardown(&mut runtime, &mut commands, &mut meshes, &mut cutout_materials);
+ }
+
+ // --- Aim the shared sun (quantised — see SUN_SNAP_RADIANS) ---
if let Some(sun) = runtime.sun {
- // Only the rotation matters: Bevy fits directional-light cascades to each view's
- // frustum and ignores the light's translation entirely. The rotation is QUANTISED —
- // written only when the live sun has drifted [`SUN_SNAP_RADIANS`] past the held
- // basis — because a per-frame rotation defeats the cascade texel snap and crawls
- // every shadow edge (see the constant's doc for the arithmetic). Skipping the write
- // also keeps the light's `Transform` change detection quiet between snaps.
if sun_snap_due(runtime.sun_written, sun_direction) {
if let Ok(mut current) = suns.get_mut(sun) {
*current = Transform::IDENTITY.looking_to(sun_direction, Vec3::Y);
@@ -391,6 +500,27 @@ fn update_world_shadow(
}
}
+/// Spawn a SOLID caster entity on the private shadow layer with the shared proxy material. Used by
+/// both the character lane (its per-frame caster) and the world lane (its cached static caster).
+pub(crate) fn spawn_solid_caster(
+ commands: &mut Commands,
+ mesh: Handle,
+ material: Handle,
+) -> Entity {
+ commands
+ .spawn((
+ Mesh3d(mesh),
+ MeshMaterial3d(material),
+ Transform::IDENTITY,
+ Visibility::Visible,
+ RenderLayers::layer(PLAYER_SHADOW_LAYER),
+ NoFrustumCulling,
+ NotShadowReceiver,
+ WorldShadowCaster,
+ ))
+ .id()
+}
+
/// Append a triangle list, dropping WHOLE triangles whose indices exceed the vertex range.
/// Filtering single indices out of a `TriangleList` shifts every subsequent index by one and
/// rewires the rest of the submesh into garbage triangles.
@@ -405,7 +535,7 @@ fn append_triangles(source: &[u32], vertex_count: u32, base: u32, indices: &mut
/// Take a caster mesh's own buffers so a rebuild reuses their allocations rather than paying
/// a fresh `Vec` growth curve every time. The mesh is left attribute-less until
/// [`restore_mesh_buffers`] puts them back.
-fn take_mesh_buffers(mesh: &mut Mesh) -> (Vec<[f32; 3]>, Vec) {
+pub(crate) fn take_mesh_buffers(mesh: &mut Mesh) -> (Vec<[f32; 3]>, Vec) {
let mut positions = match mesh.remove_attribute(Mesh::ATTRIBUTE_POSITION) {
Some(bevy::mesh::VertexAttributeValues::Float32x3(values)) => values,
_ => Vec::new(),
@@ -419,12 +549,12 @@ fn take_mesh_buffers(mesh: &mut Mesh) -> (Vec<[f32; 3]>, Vec) {
(positions, indices)
}
-fn restore_mesh_buffers(mesh: &mut Mesh, positions: Vec<[f32; 3]>, indices: Vec) {
+pub(crate) fn restore_mesh_buffers(mesh: &mut Mesh, positions: Vec<[f32; 3]>, indices: Vec) {
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_indices(Indices::U32(indices));
}
-fn empty_shadow_mesh() -> Mesh {
+pub(crate) fn empty_shadow_mesh() -> Mesh {
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::default(),
@@ -434,7 +564,23 @@ fn empty_shadow_mesh() -> Mesh {
mesh
}
-fn collect_world_geometry(
+/// A cutout caster mesh: like [`empty_shadow_mesh`] but carrying a `UV_0` lane, because the
+/// alpha-tested prepass fragment samples the leaf sheet at these UVs. The mesh MUST declare UV_0
+/// (even empty) so Bevy's prepass specializer sets the `VERTEX_UVS_A` shader-def and the fragment's
+/// `in.uv` exists — without it the cutout discard is compiled out and foliage casts solid again.
+pub(crate) fn empty_cutout_mesh() -> Mesh {
+ let mut mesh = Mesh::new(
+ PrimitiveTopology::TriangleList,
+ RenderAssetUsages::default(),
+ );
+ mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, Vec::<[f32; 3]>::new());
+ mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, Vec::<[f32; 2]>::new());
+ mesh.insert_indices(Indices::U32(Vec::new()));
+ mesh
+}
+
+#[allow(clippy::too_many_arguments)]
+fn collect_entity_geometry(
parts: &Query<
(
&PickMesh,
@@ -448,6 +594,8 @@ fn collect_world_geometry(
>,
rigs: &Query<&RigSkin>,
palettes: &RigPalettes,
+ char_on: bool,
+ world_on: bool,
light_position: Vec3,
creature_reach: f32,
doodad_reach: f32,
@@ -456,7 +604,7 @@ fn collect_world_geometry(
) -> (u32, u32) {
let (mut admitted, mut rejected) = (0u32, 0u32);
for (pick, part, global, rig_part, occluder, _material) in parts.iter() {
- if !casts_realtime_shadow(part.kind, part.blend) {
+ if !casts_realtime_shadow(part.kind, part.blend, char_on, world_on) {
continue;
}
// NOT `InheritedVisibility`: that folds the exterior window gate, the portal PVS and
@@ -469,10 +617,8 @@ fn collect_world_geometry(
continue;
}
// The distance gate, BEFORE any vertex work: the cascade resolves to CASTER_RANGE, so
- // a part beyond its lane's reach contributes nothing — skinning it first would be the
- // per-frame cost this gate exists to remove (every resident creature within far clip
- // was CPU-skinned per vertex, per frame). Creatures/gameobjects are at most a body
- // tall; doodad/WMO exiles can be whole trees and take the tall-caster reach.
+ // a part beyond its lane's reach contributes nothing. Creatures/gameobjects are at most a
+ // body tall; doodad/WMO exiles can be whole trees and take the tall-caster reach.
let reach = match part.kind {
ModelKind::Creature | ModelKind::GameObject => creature_reach,
ModelKind::Doodad | ModelKind::Wmo => doodad_reach,
@@ -509,12 +655,21 @@ fn collect_world_geometry(
(admitted, rejected)
}
-/// Wrath's character-only shadow tier. Player bodies, equipment, NPCs, creatures and mounts all
-/// use the `Creature` model lane. Static gameobjects, doodads and WMO batches keep their authored
-/// world shading and must never enter this camera-fitted realtime map.
-fn casts_realtime_shadow(kind: ModelKind, blend: ModelBlend) -> bool {
- matches!(kind, ModelKind::Creature)
- && matches!(blend, ModelBlend::Opaque | ModelBlend::AlphaTest)
+/// The per-frame entity caster's admit law. CHARACTERS (creatures/players/mounts) ride the
+/// `characterShadows` lane; entity-resident ENVIRONMENT (gameobjects, distance-faded doodads, WMO
+/// props) rides the `worldShadows` lane — so a chest or a fading tree casts with the world, not
+/// with characters. Only the BLEND gate additionally excludes additive/transparent geometry.
+fn casts_realtime_shadow(kind: ModelKind, blend: ModelBlend, char_on: bool, world_on: bool) -> bool {
+ match kind {
+ // Characters: opaque + alpha-test — hair/fringe cards are small, so casting them solid
+ // doesn't produce the ugly canopy-box artifact (and dropping them would lose hair shadows).
+ ModelKind::Creature => char_on && matches!(blend, ModelBlend::Opaque | ModelBlend::AlphaTest),
+ // Entity-resident environment: OPAQUE only. A foliage leaf-card cast solid becomes a box on
+ // the ground (this per-frame caster has no texture to alpha-test); cutout foliage in the
+ // retained world is cast leaf-shaped by the WORLD lane's `CutoutShadowCasterMaterial`, and
+ // entity-resident cutout (a doodad mid distance-fade) waits for that path to extend here.
+ _ => world_on && matches!(blend, ModelBlend::Opaque),
+ }
}
fn append_skinned(
@@ -567,24 +722,25 @@ mod tests {
use bevy::prelude::Vec3;
#[test]
- fn character_only_mode_never_admits_environment_casters() {
- assert!(casts_realtime_shadow(
- ModelKind::Creature,
- ModelBlend::Opaque
- ));
- assert!(casts_realtime_shadow(
- ModelKind::Creature,
- ModelBlend::AlphaTest
- ));
- assert!(!casts_realtime_shadow(
- ModelKind::Creature,
- ModelBlend::Blend
- ));
-
+ fn the_two_lanes_admit_independently() {
+ // Characters ride the CHARACTER lane (opaque + alpha-test); entity environment rides the
+ // WORLD lane (opaque only). Nothing casts Blend on either lane, and a lane that is off
+ // admits nothing of its kind.
+ // Both lanes on:
+ assert!(casts_realtime_shadow(ModelKind::Creature, ModelBlend::Opaque, true, true));
+ assert!(casts_realtime_shadow(ModelKind::Creature, ModelBlend::AlphaTest, true, true));
+ assert!(!casts_realtime_shadow(ModelKind::Creature, ModelBlend::Blend, true, true));
for kind in [ModelKind::GameObject, ModelKind::Doodad, ModelKind::Wmo] {
- assert!(!casts_realtime_shadow(kind, ModelBlend::Opaque));
- assert!(!casts_realtime_shadow(kind, ModelBlend::AlphaTest));
+ assert!(casts_realtime_shadow(kind, ModelBlend::Opaque, true, true));
+ assert!(!casts_realtime_shadow(kind, ModelBlend::AlphaTest, true, true));
+ assert!(!casts_realtime_shadow(kind, ModelBlend::Blend, true, true));
}
+ // Character lane OFF → creatures cast nothing; the world lane still casts environment.
+ assert!(!casts_realtime_shadow(ModelKind::Creature, ModelBlend::Opaque, false, true));
+ assert!(casts_realtime_shadow(ModelKind::Doodad, ModelBlend::Opaque, false, true));
+ // World lane OFF → environment casts nothing; the character lane still casts creatures.
+ assert!(!casts_realtime_shadow(ModelKind::Doodad, ModelBlend::Opaque, true, false));
+ assert!(casts_realtime_shadow(ModelKind::Creature, ModelBlend::Opaque, true, false));
}
#[test]
@@ -620,15 +776,14 @@ mod tests {
// 45° sun: horizontal == vertical, a shadow reaches exactly one caster height.
let diagonal = Vec3::new(1.0, -1.0, 0.0).normalize();
assert!((shadow_reach_extension(diagonal) - MAX_CASTER_HEIGHT).abs() < 1e-3);
- // The night lighting sun (~20° elevation): ~2.7× height, still under the cap — the
- // Goldshire/Duskwood long-shadow case the reach law exists for.
+ // The night lighting sun (~20° elevation): ~2.7× height, still under the cap.
let e = 20.0f32.to_radians();
let night = Vec3::new(e.cos(), -e.sin(), 0.0);
let expected = MAX_CASTER_HEIGHT * e.cos() / e.sin();
assert!((shadow_reach_extension(night) - expected).abs() < 1e-2);
assert!(expected < SHADOW_REACH_CAP);
- // Straight down sheds no shadow beyond the caster; a degenerate horizontal
- // direction (the NEG_Z fallback for a zeroed sun) takes the cap, never unbounded.
+ // Straight down sheds no shadow beyond the caster; a degenerate horizontal direction
+ // (the NEG_Z fallback for a zeroed sun) takes the cap, never unbounded.
assert_eq!(shadow_reach_extension(Vec3::NEG_Y), 0.0);
assert_eq!(shadow_reach_extension(Vec3::NEG_Z), SHADOW_REACH_CAP);
// The collection law always contains the resolve range plus the rebuild margin.
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index f05cfccf..b6c3f6b3 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -585,9 +585,12 @@ pub(crate) const REGISTERED: &[Registered] = &[
// `gxRestart = 1` does not apply (wgpu swaps the presentation interval live, so the box takes
// effect on click), and `$WOW_NOVSYNC=1` overrides it session-only, below.
same("gxVSync", "1"),
- // Benilla's opt-in Wrath-style character shadow-map path. Static world assets keep their
- // authored shading; players, NPCs, creatures and mounts cast realtime silhouettes.
+ // Benilla's opt-in realtime shadow-map path, split into two INDEPENDENT lanes over one shared
+ // shadow rig (one sun / one map). `worldShadows` = the static world (trees, buildings, foliage)
+ // casts realtime shadows and baked MCSH terrain shadows switch off; `characterShadows` =
+ // players/NPCs/creatures/mounts cast realtime silhouettes instead of the legacy oval blob.
same("worldShadows", "0"),
+ same("characterShadows", "0"),
// **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
@@ -1068,6 +1071,7 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
// watches the value and pushes it to the window; nothing else reads it.
"gxvsync" => knobs.video.vsync = v != 0.0,
"worldshadows" => knobs.video.world_shadows = v != 0.0,
+ "charactershadows" => knobs.video.character_shadows = v != 0.0,
// 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.
@@ -1370,7 +1374,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
- let session: [(&str, String); 45] = [
+ let session: [(&str, String); 46] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -1417,6 +1421,7 @@ fn sync_cvars(
("minimapInsideZoom", minimap.inside.to_string()),
("gxVSync", flag(video.vsync)),
("worldShadows", flag(video.world_shadows)),
+ ("characterShadows", flag(video.character_shadows)),
// The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state.
(
"gxWindow",
@@ -1830,6 +1835,10 @@ mod tests {
d["worldShadows"] != 0.0,
VideoConfig::default().world_shadows
);
+ assert_eq!(
+ d["characterShadows"] != 0.0,
+ VideoConfig::default().character_shadows
+ );
// The pane half-rate (1444) welds to the portrait knob's shipped default.
assert_eq!(d["boothHalfRate"] != 0.0, PaneRate::default().half);
// Render scale (1639) welds to OFF. Not a taste default: the whole tree of visual
diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs
index 1db62a0b..494a9bf6 100644
--- a/crates/benilla-app/src/lib.rs
+++ b/crates/benilla-app/src/lib.rs
@@ -91,9 +91,9 @@ mod npc_text;
mod pending_item_ops;
#[cfg(feature = "dev")]
mod perf;
+mod character_shadow;
mod pipe_warm;
mod player;
-mod player_shadow;
mod poi_marker;
mod portrait;
#[cfg(feature = "dev")]
@@ -181,6 +181,7 @@ mod ui_world_map;
mod video;
mod vplates;
mod world_backdrop;
+mod world_shadow;
mod world_state;
mod world_state_ui;
@@ -616,7 +617,7 @@ pub fn run(build: BuildId) -> AppExit {
// The video knobs the CVar host writes into (today: `gxVSync`). Before CvarPlugin so the
// resource exists when `load_config` applies the saved value at Startup.
.add_plugins(video::VideoPlugin)
- .add_plugins(player_shadow::WorldShadowPlugin)
+ .add_plugins(character_shadow::ShadowPlugin)
// 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`.
.add_plugins(realmlist::RealmlistPlugin)
diff --git a/crates/benilla-app/src/shaders.rs b/crates/benilla-app/src/shaders.rs
index f4b4b473..55ab58a5 100644
--- a/crates/benilla-app/src/shaders.rs
+++ b/crates/benilla-app/src/shaders.rs
@@ -25,6 +25,7 @@ pub(crate) fn plugin(app: &mut App) {
bevy::asset::embedded_asset!(app, "shaders/ui_node_gamma.wgsl");
bevy::asset::embedded_asset!(app, "shaders/ui_slice_gamma.wgsl");
bevy::asset::embedded_asset!(app, "shaders/shadow_caster.wgsl");
+ bevy::asset::embedded_asset!(app, "shaders/shadow_caster_cutout_prepass.wgsl");
}
#[cfg(test)]
@@ -60,6 +61,6 @@ mod tests {
`shaders::plugin`, or was registered from a file that is not directly under src/"
);
}
- assert_eq!(found, 6, "the game's shader set changed size");
+ assert_eq!(found, 7, "the game's shader set changed size");
}
}
diff --git a/crates/benilla-app/src/shaders/shadow_caster_cutout_prepass.wgsl b/crates/benilla-app/src/shaders/shadow_caster_cutout_prepass.wgsl
new file mode 100644
index 00000000..bc709a82
--- /dev/null
+++ b/crates/benilla-app/src/shaders/shadow_caster_cutout_prepass.wgsl
@@ -0,0 +1,63 @@
+// MONKEY (world shadows): alpha-tested foliage shadow caster — the PREPASS fragment.
+//
+// The proxy caster is invisible in the camera's forward pass (its forward fragment `discard`s every
+// fragment — `shadow_caster.wgsl`). Its material is `AlphaMode::Mask`, which sets Bevy's
+// `MAY_DISCARD` pipeline key; paired with this EXPLICIT prepass fragment shader that decides Bevy
+// runs a fragment during the directional-light SHADOW pass (which is otherwise depth-only). So a
+// leaf card can discard its transparent texels and cast a leaf-SHAPED silhouette instead of the
+// solid rectangular box a positions-only proxy throws.
+//
+// The leaf sheet is bound as the material's own texture at group `MATERIAL_BIND_GROUP` — the
+// prepass material slot (index 3; substituted by the prepass pipeline). Sampling the SAME image the
+// forward pass draws keeps the clamp/repeat address mode identical, so the shadow silhouette
+// matches the visible foliage exactly (a cutout card's UVs run outside 0..1 into the transparent
+// border — sampled with the wrong wrap mode the margin wraps back into the opaque middle and the
+// card casts solid again).
+//
+// Structure mirrors Bevy's own `pbr_prepass.wgsl`: a `FragmentOutput`-returning entry under
+// `PREPASS_FRAGMENT` (the unclipped-depth-emulation path for GPUs without `depth_clip_control`) and
+// a void entry otherwise (the common desktop depth-only path); both run the same cutout discard.
+
+#import bevy_pbr::prepass_io
+
+@group(#{MATERIAL_BIND_GROUP}) @binding(0) var leaf_texture: texture_2d;
+@group(#{MATERIAL_BIND_GROUP}) @binding(1) var leaf_sampler: sampler;
+
+// WoW ≤ WotLK alpha-test key (`benilla_assets::materials::VANILLA_ALPHA_KEY_REF` = 224/255): the
+// value below which a cutout fragment is discarded. The exact ref the forward cutout pass uses.
+const VANILLA_ALPHA_KEY: f32 = 0.8784314;
+
+// Sample the leaf sheet at the interpolated UV and discard transparent texels, exactly as the
+// forward cutout does. Sampled BEFORE any `discard` so the texture access stays in uniform control
+// flow. A caster mesh always carries `UV_0` (`VERTEX_UVS_A`); lacking it, nothing discards (the
+// card falls back to a solid cast rather than vanishing).
+fn cutout_discard(in: prepass_io::VertexOutput) {
+#ifdef VERTEX_UVS_A
+ let alpha = textureSample(leaf_texture, leaf_sampler, in.uv).a;
+ if alpha < VANILLA_ALPHA_KEY {
+ discard;
+ }
+#endif
+}
+
+#ifdef PREPASS_FRAGMENT
+// A GPU without `depth_clip_control`: Bevy emulates the directional-shadow view's unclipped depth
+// by writing `frag_depth` in the fragment (`prepass/mod.rs`, `UNCLIPPED_DEPTH_ORTHO_EMULATION`).
+// Mirror the default prepass fragment's single field so that depth-clamp fix still applies here.
+@fragment
+fn fragment(in: prepass_io::VertexOutput) -> prepass_io::FragmentOutput {
+ cutout_discard(in);
+ var out: prepass_io::FragmentOutput;
+#ifdef UNCLIPPED_DEPTH_ORTHO_EMULATION
+ out.frag_depth = in.unclipped_depth;
+#endif // UNCLIPPED_DEPTH_ORTHO_EMULATION
+ return out;
+}
+#else
+// The common desktop path: the shadow pass is depth-only (no color targets), so the fragment
+// returns nothing and exists ONLY to run the cutout discard against the depth write.
+@fragment
+fn fragment(in: prepass_io::VertexOutput) {
+ cutout_discard(in);
+}
+#endif // PREPASS_FRAGMENT
diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs
index 0f809e7f..380428bc 100644
--- a/crates/benilla-app/src/ui_script/options_tests.rs
+++ b/crates/benilla-app/src/ui_script/options_tests.rs
@@ -2184,6 +2184,7 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
"BENILLA_TOOLTIP_BACKGROUND_SOUND",
"AudioRowBackgroundSound",
),
+ ("BENILLA_TOOLTIP_CHARACTER_SHADOWS", "GraphicsRowCharacterShadows"),
("BENILLA_TOOLTIP_WORLD_SHADOWS", "GraphicsRowWorldShadows"),
];
if let Some((_, want_row)) = BENILLA_OWNED.iter().find(|(k, _)| *k == key) {
@@ -2229,7 +2230,9 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// 29th is Enable Error Speech (1815), 1.12's own fourth Sound checkbox, ditto; the 30th is
// Enable Sound in Background (1847), the Audio page's fifth checkbox and the one row on that
// page the reference never made settable.
- assert_eq!(checked, 59, "every tipped row carries a live key");
+ // 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");
assert_eq!(
untipped,
vec![
diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs
index 92de116d..41c36c1a 100644
--- a/crates/benilla-app/src/video.rs
+++ b/crates/benilla-app/src/video.rs
@@ -215,8 +215,13 @@ pub(crate) fn boot_windowed_size() -> UVec2 {
#[derive(Resource, Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) struct VideoConfig {
pub(crate) vsync: bool,
- /// Whether characters use the isolated real shadow-map path.
+ /// Whether the STATIC WORLD (trees, buildings, foliage) casts realtime shadows and baked MCSH
+ /// terrain shadows are suppressed. Independent of [`Self::character_shadows`] — either drives
+ /// the shared shadow rig (`character_shadow` / `world_shadow`).
pub(crate) world_shadows: bool,
+ /// 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,
pub(crate) display: DisplayMode,
/// The windowed size, `gxResolution`. Kept while fullscreen so leaving it can restore it.
pub(crate) windowed: UVec2,
@@ -227,6 +232,7 @@ impl Default for VideoConfig {
Self {
vsync: !novsync_env(),
world_shadows: false,
+ character_shadows: false,
display: if windowed_env() {
DisplayMode::Windowed
} else {
@@ -538,6 +544,7 @@ mod tests {
app.insert_resource(VideoConfig {
vsync: true,
world_shadows: false,
+ character_shadows: false,
display: DisplayMode::Fullscreen,
windowed: UVec2::new(1024, 768),
})
diff --git a/crates/benilla-app/src/world_shadow.rs b/crates/benilla-app/src/world_shadow.rs
new file mode 100644
index 00000000..67682117
--- /dev/null
+++ b/crates/benilla-app/src/world_shadow.rs
@@ -0,0 +1,164 @@
+//! MONKEY (world shadows): the WORLD shadow lane — a self-contained, plug-and-play module.
+//!
+//! This owns the realtime shadows cast by the STATIC world: the retained `static_gx` geometry
+//! (trees + buildings, collected solid) and the alpha-tested foliage (leaf canopies, one caster per
+//! leaf texture). It is deliberately independent of [`super::character_shadow`]'s character lane:
+//! the two share ONE rig (the sun + private render layer + shadow map that `character_shadow` owns)
+//! but neither lane's casters depend on the other's. Enabling/disabling the world lane
+//! (`worldShadows`) spawns/tears down only the entities below; removing the feature entirely means
+//! deleting this module + its two calls in `character_shadow::update_shadows` + the cvar.
+//!
+//! Cadence: the static world doesn't move, so both casters rebuild only when the camera has drifted
+//! [`STATIC_REBUILD_STEP`] (the character lane, by contrast, rebuilds every frame). Between rebuilds
+//! the cached meshes stay valid.
+
+use bevy::asset::AssetId;
+use bevy::camera::visibility::{NoFrustumCulling, RenderLayers};
+use bevy::image::Image;
+use bevy::light::NotShadowReceiver;
+use bevy::mesh::Indices;
+use bevy::pbr::MeshMaterial3d;
+use bevy::platform::collections::HashSet;
+use bevy::prelude::*;
+
+use benilla_world::static_gx::StaticGx;
+
+use super::character_shadow::{
+ empty_cutout_mesh, empty_shadow_mesh, restore_mesh_buffers, shadow_trace, spawn_solid_caster,
+ take_mesh_buffers, CutoutCaster, CutoutShadowCasterMaterial, ShadowCasterMaterial,
+ ShadowRuntime, WorldShadowCaster, PLAYER_SHADOW_LAYER, STATIC_REBUILD_STEP,
+};
+
+/// Ensure the world lane's casters exist and, on camera drift, refresh them from the retained
+/// static world. The solid caster shares the rig's invisible proxy material (`solid_material`); the
+/// cutout casters each carry their own leaf sheet. Called only while `worldShadows` is on.
+pub(crate) fn update(
+ runtime: &mut ShadowRuntime,
+ commands: &mut Commands,
+ meshes: &mut Assets,
+ solid_material: &Handle,
+ cutout_materials: &mut Assets,
+ gx: &StaticGx,
+ light_position: Vec3,
+ tall_reach: f32,
+) {
+ // Ensure the static SOLID caster (trunks, buildings, fences — the opaque static geometry).
+ if runtime.static_caster.is_none() {
+ let mesh = meshes.add(empty_shadow_mesh());
+ let entity = spawn_solid_caster(commands, mesh.clone(), solid_material.clone());
+ runtime.static_caster = Some(entity);
+ runtime.static_mesh = Some(mesh);
+ runtime.static_rebuilt_at = None; // force a rebuild on the first frame
+ }
+
+ // Static geometry doesn't move: rebuild only when the camera has drifted a step. Between
+ // rebuilds the cached meshes stay valid, so this is a cheap early-out most frames.
+ let due = runtime
+ .static_rebuilt_at
+ .map_or(true, |at| at.distance(light_position) > STATIC_REBUILD_STEP);
+ if !due {
+ return;
+ }
+
+ // The SOLID pass: the retained static world's opaque geometry, minus the cutout foliage it skips.
+ if let Some(handle) = runtime.static_mesh.clone() {
+ if let Some(mesh) = meshes.get_mut(&handle) {
+ let (mut positions, mut indices) = take_mesh_buffers(mesh);
+ gx.append_shadow_triangles(light_position, tall_reach, &mut positions, &mut indices);
+ let static_tris = (indices.len() / 3) as u32;
+ restore_mesh_buffers(mesh, positions, indices);
+ runtime.static_rebuilt_at = Some(light_position);
+ if shadow_trace() {
+ info!(
+ "shadow-trace: static rebuild — {} tris within {:.0}yd",
+ static_tris, tall_reach
+ );
+ }
+ }
+ }
+
+ // The ALPHA-TESTED foliage pass: the cutout leaf cards the solid pass skipped, grouped one
+ // caster mesh + material per leaf texture so the shadow pass can sample the sheet and cast a
+ // leaf-SHAPED silhouette (see `CutoutShadowCasterMaterial`).
+ let buckets = gx.collect_cutout_shadow_triangles(light_position, tall_reach);
+ let mut seen: HashSet> = HashSet::new();
+ let mut cutout_tris = 0u32;
+ for bucket in buckets {
+ seen.insert(bucket.texture_id);
+ cutout_tris += (bucket.indices.len() / 3) as u32;
+ // Spawn a persistent caster the first time a leaf texture appears.
+ if !runtime.static_cutout.contains_key(&bucket.texture_id) {
+ let mesh = meshes.add(empty_cutout_mesh());
+ let material = cutout_materials.add(CutoutShadowCasterMaterial {
+ leaf: bucket.texture.clone(),
+ });
+ let entity = commands
+ .spawn((
+ Mesh3d(mesh.clone()),
+ MeshMaterial3d(material.clone()),
+ Transform::IDENTITY,
+ Visibility::Visible,
+ RenderLayers::layer(PLAYER_SHADOW_LAYER),
+ NoFrustumCulling,
+ NotShadowReceiver,
+ WorldShadowCaster,
+ ))
+ .id();
+ runtime
+ .static_cutout
+ .insert(bucket.texture_id, CutoutCaster { entity, mesh, material });
+ }
+ // Clone the handle out so the `runtime` borrow ends before `meshes.get_mut`.
+ let mesh_handle = runtime.static_cutout[&bucket.texture_id].mesh.clone();
+ if let Some(mesh) = meshes.get_mut(&mesh_handle) {
+ mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, bucket.positions);
+ mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, bucket.uvs);
+ mesh.insert_indices(Indices::U32(bucket.indices));
+ }
+ }
+ // Despawn casters whose leaf texture left the reach, so the set stays bounded as the camera
+ // crosses zones (a texture no longer present casts nothing anyway).
+ let stale: Vec> = runtime
+ .static_cutout
+ .keys()
+ .filter(|id| !seen.contains(*id))
+ .copied()
+ .collect();
+ for id in stale {
+ if let Some(caster) = runtime.static_cutout.remove(&id) {
+ commands.entity(caster.entity).despawn();
+ meshes.remove(caster.mesh.id());
+ cutout_materials.remove(caster.material.id());
+ }
+ }
+ if shadow_trace() {
+ info!(
+ "shadow-trace: cutout rebuild — {} tris across {} leaf textures",
+ cutout_tris,
+ runtime.static_cutout.len()
+ );
+ }
+}
+
+/// Tear down every world-lane caster (the solid static caster + all per-texture foliage casters).
+/// Called when `worldShadows` is off, or by the rig teardown when both lanes go dark. Leaves the
+/// shared rig (sun/layer/material) alone — that is `character_shadow`'s to manage.
+pub(crate) fn teardown(
+ runtime: &mut ShadowRuntime,
+ commands: &mut Commands,
+ meshes: &mut Assets,
+ cutout_materials: &mut Assets,
+) {
+ if let Some(entity) = runtime.static_caster.take() {
+ commands.entity(entity).despawn();
+ }
+ if let Some(handle) = runtime.static_mesh.take() {
+ meshes.remove(handle.id());
+ }
+ runtime.static_rebuilt_at = None;
+ for (_texture, caster) in runtime.static_cutout.drain() {
+ commands.entity(caster.entity).despawn();
+ meshes.remove(caster.mesh.id());
+ cutout_materials.remove(caster.material.id());
+ }
+}
diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl
index 9c7fc949..5aa629db 100644
--- a/crates/benilla-assets/src/shaders/terrain.wgsl
+++ b/crates/benilla-assets/src/shaders/terrain.wgsl
@@ -325,10 +325,15 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 {
let mcsh = textureSample(shadow_array, splat_samp, auv, i32(round(si))).r;
shadow_lit = 1.0 - mcsh;
}
- // MCSH always remains the authored base. Realtime character coverage applies a neutral
- // attenuation and closes the sheen gate. This makes the result monotonic and hue-preserving:
- // a character can only darken the terrain already underneath it.
- let shadow_lit_eff = shadow_lit;
+ // MONKEY (world shadows): when the realtime WORLD-shadow lane is active (`worldShadows` on), the
+ // BAKED MCSH terrain shadows are redundant — the realtime map now shadows the static world
+ // (trees + buildings) too — and keeping both double-shadows. So drop MCSH then. This keys on the
+ // world lane flag (`sh_c16.w`, packed by `global_light::build_light_data`), NOT on the mere
+ // presence of a directional light: the shared shadow sun ALSO exists for CHARACTER-only shadows,
+ // and those must leave the world's baked MCSH intact. With the world lane off, `shadow_lit_eff`
+ // keeps MCSH and only the realtime `character_shadow_term` adds the dynamic character shadow.
+ let world_shadow_lane = wow_light.sh_c16.w > 0.5;
+ let shadow_lit_eff = select(shadow_lit, 1.0, world_shadow_lane);
let spec_gate = min(shadow_lit, world_shadow);
let character_shadow_term = mix(SHADOW_SUN_FLOOR, 1.0, world_shadow);
diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs
index 8c50b684..94a1e816 100644
--- a/crates/benilla-world/src/lighting/global_light.rs
+++ b/crates/benilla-world/src/lighting/global_light.rs
@@ -175,6 +175,15 @@ const POINT_PACK_RADIUS: f32 = 300.0;
#[derive(Component)]
pub struct LightRooms(pub(crate) crate::wmo_portal::WmoGroupVis);
+/// MONKEY (world shadows): whether the realtime WORLD-shadow lane is active this frame (the
+/// `worldShadows` cvar). Set by benilla-app's shadow rig; read by [`build_light_data`], which packs
+/// it into the free `sh_c16.w` light lane so `terrain.wgsl` can suppress the baked MCSH terrain
+/// shadows ONLY when the world is casting realtime — not merely because a shadow sun exists (the
+/// sun also exists for character-only shadows, which must leave MCSH alone). A dedicated resource
+/// rather than a `WowLighting` field because `update_time_lighting` wholesale-overwrites that.
+#[derive(Resource, Clone, Copy, Default)]
+pub struct WorldShadowActive(pub bool);
+
/// 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)]
@@ -201,6 +210,7 @@ pub struct SharedLightBuffer(pub Buffer);
/// creation, and the render-world upload.
pub(super) fn register(app: &mut App) {
app.init_resource::()
+ .init_resource::()
.init_resource::()
.add_plugins(ExtractResourcePlugin::::default())
.add_plugins(ExtractResourcePlugin::::default())
@@ -276,6 +286,8 @@ fn build_light_data(
portals: Query<&crate::wmo_portal::WmoPortalInstance>,
mut data: ResMut,
time: Res