From fab487dada5d0f7e34935425f50508de4b325e3f Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Sun, 23 Aug 2026 21:44:43 +0100
Subject: [PATCH 01/17] Fork setup: add MONKEY-FORK maintenance catalogue
Establish benilla-everwood as our fork of samwhosung/benilla.
Documents the vendor-branch update workflow, the benilla-monkey
plugin-crate design, the catalogue of upstream edits, the ~10
mapi functions to reimplement natively, and the staged de-risk plan.
Co-Authored-By: Claude Opus 4.8
---
MONKEY-FORK.md | 82 ++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 82 insertions(+)
create mode 100644 MONKEY-FORK.md
diff --git a/MONKEY-FORK.md b/MONKEY-FORK.md
new file mode 100644
index 00000000..7dd28177
--- /dev/null
+++ b/MONKEY-FORK.md
@@ -0,0 +1,82 @@
+# MONKEY-FORK — benilla-everwood
+
+Our fork of **[samwhosung/benilla](https://github.com/samwhosung/benilla)** (a from-scratch Rust +
+Bevy reimplementation of the 1.12.1 / 5875 client). Goal: run the Everwood / "Monkey WoW" custom
+content — the features currently delivered on the stock Blizzard client via **injected `mapi.dll` /
+`phys.dll`** — as **native code in an open client** instead.
+
+Upstream is a solo project, published as **squashed snapshots from a private tree, with issues and
+PRs closed**. Nothing we do here can ever land upstream — we carry our changes forever. So this file
+is the contract that keeps re-applying them across upstream drops cheap.
+
+## Branch topology
+
+| branch | role | rule |
+|---|---|---|
+| `vendor` | pristine mirror of `upstream/main` | **never commit here** — only `reset --hard` |
+| `everwood` | our work (this fork's default) | the `benilla-monkey` crate + the catalogued edits below |
+
+Remotes: `upstream` = samwhosung/benilla · `origin` = pkuzic/benilla-everwood.
+
+## Updating to a new upstream drop
+
+```bash
+git fetch upstream
+git checkout vendor && git reset --hard upstream/main # works even if upstream re-squashed / force-pushed
+git checkout everwood && git rebase vendor # replays our small commit series on top
+```
+
+Conflicts can only occur in the few upstream files we edit (see catalogue). `Cargo.lock` conflict →
+`git checkout --theirs Cargo.lock` then `cargo build` to regenerate. After every rebase, re-run the
+addon harness as a regression gate (below).
+
+## Design rule: keep our surface tiny
+
+All custom logic lives in a **new crate `crates/benilla-monkey/`** (a Bevy plugin). The workspace is
+`members = ["crates/*"]`, so a new crate is auto-included and **cannot merge-conflict**. benilla
+already registers Lua globals from Bevy plugins (its `ProbeLuaPlugin` does
+`script.lua().create_function()` + `globals().set(...)`); `UiScript` exposes `.lua()` /
+`.register_bindings()`; rig/pose data is public (`RigPose`, `RigPalettes`, `RigSkin`, `PosePost`).
+When a refactor breaks us it surfaces as a **compile error in our crate** pointing at the renamed
+symbol — not a silent wipe of edits scattered through theirs.
+
+## Catalogue of edits to UPSTREAM files
+
+Every edit outside `crates/benilla-monkey/` goes here and is marked in-source with `// MONKEY:`.
+Keep this list short; prefer moving logic into the crate.
+
+| file | edit | why | status |
+|---|---|---|---|
+| `crates/benilla-app/src/lib.rs` (`run`) | `app.add_plugins(MonkeyPlugin)` | wire our plugin in (no public plugin-group entry yet) | **planned** |
+| `crates/benilla-ui` / `assets/ui` | publish `TargetLevelText` / `PlayerLevelText` regions | MonkeyCharsheet hooks these stock globals | **planned** |
+| (register `PlayerModel` frame type) | `CreateFrame("PlayerModel", …)` | BuildUI + ObjectBrowser 3D previews | **planned** |
+
+## The ~10 functions to reimplement natively
+
+Currently injected by `mapi.dll` (`custom/client/mapi/dllmain.cpp` `g_api[]`). Only 4 addons use
+them; the other 10 Monkey addons are pure FrameXML.
+
+- **Projection / camera** (easy): `WorldToScreen`, `MonkeyProj`, `MonkeyCamLive`
+- **Model spawn / transform** (cleaner than the DLL hacks — we own the loader): `MonkeyCreateModel`,
+ `MonkeySetModelTransform`, `MonkeyDestroyModel`; `MonkeyPathByte` becomes unnecessary
+- **GO model reuse** (obsolete — just spawn our own entity): `MonkeyHijackGO`, `MonkeyRestoreGO`,
+ `MonkeyDriveGO`
+- **Bone override** (deepest — via `RigPose` around `PosePost`): `MonkeySetScratchBone`, `MonkeyBendBone`
+
+## De-risk plan (staged)
+
+1. Plugin PoC — register `WorldToScreen` from `benilla-monkey`, confirm a Lua call returns real coords.
+2. FrameXML fixes — `PlayerModel` + `TargetLevelText`/`PlayerLevelText`; re-harness (10/14 → ~13/14).
+3. Live UI smoke test vs the Everwood server (real pixels + interaction).
+4. Native `MonkeySetScratchBone` via `RigPose` — unblocks MonkeyBodyShape end-to-end.
+
+## Regression harness
+
+benilla ships a headless addon corpus harness. Point it at a folder of our addons:
+
+```bash
+WOW_DATA=G:/twmoa_1181/Data cargo run -q -p benilla-app --example addon_harness -- --verbose
+```
+
+Baseline (2026-08-23, before any fix): **12/14 load, 10/14 survive a session**; read the custom
+MPQ/DBC patch chain without crashing.
From 7f4db0e87983a1b9ddb2f0427df0c20b9169515e Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Wed, 2 Sep 2026 16:13:21 +0100
Subject: [PATCH 02/17] MONKEY: split AUTH_BUILD (realmd 7272) from
CLIENT_BUILD (world 5875)
realmd's FindBuildInfo accepts only build >= 7272; mangosd's
IsAcceptableClientBuild needs exactly 5875. Present 7272 to the auth
challenge, 5875 to the world/login-screen. Catalogue entry in
MONKEY-FORK.md marked LANDED.
Co-Authored-By: Claude Opus 4.8
---
MONKEY-FORK.md | 12 ++++++++++++
crates/benilla-protocol/src/lib.rs | 15 +++++++++++++--
2 files changed, 25 insertions(+), 2 deletions(-)
diff --git a/MONKEY-FORK.md b/MONKEY-FORK.md
index 7dd28177..bd815552 100644
--- a/MONKEY-FORK.md
+++ b/MONKEY-FORK.md
@@ -47,9 +47,21 @@ Keep this list short; prefer moving logic into the crate.
| file | edit | why | status |
|---|---|---|---|
+| `crates/benilla-protocol/src/lib.rs` | **split build:** `CLIENT_BUILD` = 5875 (world/mangosd + login-screen), new `AUTH_BUILD` = 7272 (realmd challenge, `lib.rs` call site) | the two servers demand different builds — realmd `FindBuildInfo` needs ≥ 7272, mangosd `IsAcceptableClientBuild` needs exactly 5875 (matches the twmoa client: reports 1.18.1 to realmd, 5875 engine to world). | **LANDED** |
| `crates/benilla-app/src/lib.rs` (`run`) | `app.add_plugins(MonkeyPlugin)` | wire our plugin in (no public plugin-group entry yet) | **planned** |
| `crates/benilla-ui` / `assets/ui` | publish `TargetLevelText` / `PlayerLevelText` regions | MonkeyCharsheet hooks these stock globals | **planned** |
| (register `PlayerModel` frame type) | `CreateFrame("PlayerModel", …)` | BuildUI + ObjectBrowser 3D previews | **planned** |
+| `crates/benilla-protocol/src/auth.rs` | treat proof reply `0x00`+`0x09` as version-invalid | so a build/version mismatch reads clearly, not "got 0x0" | **idea** |
+
+## Server-side requirements (Everwood realmd, not benilla edits)
+
+To let benilla (or any non-original client) authenticate against `bin/Debug/realmd.conf`:
+- **`StrictVersionCheck = 0`** (default 1) + restart realmd — otherwise realmd checks the client
+ integrity hash, which benilla (not the original binary) can't reproduce → "modified client".
+- Log in with a **non-GM account** (rank < `ForcePinAccountRank`, default 7) — GM-rank accounts are
+ forced through a PIN benilla doesn't implement.
+- benilla must present **build 7272** (the `CLIENT_BUILD` patch above), which realmd's
+ `ExpectedRealmdClientBuilds` requires.
## The ~10 functions to reimplement natively
diff --git a/crates/benilla-protocol/src/lib.rs b/crates/benilla-protocol/src/lib.rs
index b1dbd6e3..149d7488 100644
--- a/crates/benilla-protocol/src/lib.rs
+++ b/crates/benilla-protocol/src/lib.rs
@@ -35,8 +35,18 @@ use benilla_srp::{NormalizedString, PublicKey, SrpClientChallenge, SESSION_KEY_L
/// The realmd (auth/login) server port — the stock one a vmangos `realmd` listens on, which our
/// deploy maps straight through (`3724:3724` in the compose file at `vmangos-deploy`).
pub const AUTH_PORT: u16 = 3724;
-/// The 1.12.1 client build we present to the server.
+/// The engine build we present to the WORLD server (mangosd) and show on the login screen.
+// MONKEY: the two Everwood servers demand DIFFERENT builds. mangosd's IsAcceptableClientBuild
+// (src/game/Database/DBCStores.cpp) accepts ONLY 5875, and the twmoa client itself displays
+// "1.12.1 (5875)" — so the world/engine build stays 5875 (sending 7272 here → mangosd
+// "HandleAuthSession: version mismatch"). Upstream default was 5875 (unchanged).
pub const CLIENT_BUILD: u16 = 5875;
+/// The build we present to the AUTH server (realmd) in the logon challenge.
+// MONKEY: realmd's FindBuildInfo (src/realmd/RealmList.cpp) accepts only build >= 7272 (twmoa
+// 1.18.1). Sending 5875 to realmd → CMD_AUTH_LOGON_CHALLENGE(0x00)+WOW_FAIL_VERSION_INVALID
+// ("got 0x0"). The twmoa client reports its 1.18.1 version to realmd but the 5875 engine build to
+// the world, so these two builds MUST differ.
+pub const AUTH_BUILD: u16 = 7272;
/// How many logon challenges [`logon`] will ask for while looking for a `B` both serialization
/// conventions read the same way (see the redial comment there). One dial in ~137 comes back
/// ambiguous, so eight is already a probability of about 10⁻¹⁷ of running out.
@@ -166,7 +176,8 @@ pub fn logon(host: &str, username: &str, password: &str) -> Result {
let mut dialed = None;
for _ in 0..MAX_CHALLENGE_DIALS {
let mut stream = dial(host, port)?;
- auth::write_logon_challenge(&mut stream, &username.to_uppercase(), CLIENT_BUILD)
+ // MONKEY: AUTH_BUILD (7272) to realmd, not CLIENT_BUILD (5875) — see the const docs.
+ auth::write_logon_challenge(&mut stream, &username.to_uppercase(), AUTH_BUILD)
.context("sending logon challenge")?;
let reply =
auth::read_challenge_reply(&mut stream).context("reading logon challenge reply")?;
From 50771c7c05422031624b9186f6c1474da4f4a1ce Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Wed, 2 Sep 2026 23:49:32 +0100
Subject: [PATCH 03/17] Apply reference real-character-shadows patch (Bevy
shadow-map approach)
Character-only realtime shadows via a DirectionalLight + a private-layer
CPU-skinned caster proxy; terrain/models/static_gx receive through Bevy's
shadows::fetch_directional_shadow. Opt-in cvar `worldShadows` (default off);
hides the blob oval when on. Branched from 7f4db0e (clean pre-shadow base).
Conflicts resolved: cvars.rs (our `same()` style + 45-entry session array),
options_tests.rs (our row counts +1 for the World Shadows toggle).
Co-Authored-By: Claude Opus 4.8
---
.gitignore | 3 +
crates/benilla-app/assets/ui/OptionsFrame.xml | 19 +-
crates/benilla-app/src/blob_shadow.rs | 14 +
crates/benilla-app/src/cvars.rs | 11 +-
crates/benilla-app/src/lib.rs | 7 +-
crates/benilla-app/src/player_shadow.rs | 653 ++++++++++++++++++
crates/benilla-app/src/shaders.rs | 3 +-
.../src/shaders/shadow_caster.wgsl | 7 +
.../src/ui_script/options_tests.rs | 5 +-
crates/benilla-app/src/video.rs | 4 +
crates/benilla-assets/src/materials.rs | 6 +
.../benilla-assets/src/shaders/terrain.wgsl | 45 +-
.../benilla-assets/src/shaders/wow_model.wgsl | 69 +-
crates/benilla-world/src/model_render.rs | 18 +
.../src/model_render/visibility.rs | 11 +
.../benilla-world/src/shaders/static_gx.wgsl | 79 ++-
crates/benilla-world/src/static_gx/mod.rs | 9 +-
crates/benilla-world/src/static_gx/render.rs | 110 ++-
18 files changed, 1021 insertions(+), 52 deletions(-)
create mode 100644 crates/benilla-app/src/player_shadow.rs
create mode 100644 crates/benilla-app/src/shaders/shadow_caster.wgsl
diff --git a/.gitignore b/.gitignore
index 706cf583..7664883e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,6 @@ trace-*.json
.DS_Store
*.swp
/.idea/
+
+# MONKEY: per-run client state written by benilla (saved variables, camera) — not source.
+/benilla-config/
diff --git a/crates/benilla-app/assets/ui/OptionsFrame.xml b/crates/benilla-app/assets/ui/OptionsFrame.xml
index 34096572..39320152 100644
--- a/crates/benilla-app/assets/ui/OptionsFrame.xml
+++ b/crates/benilla-app/assets/ui/OptionsFrame.xml
@@ -677,6 +677,11 @@
.. "There is no exclusive fullscreen mode. WoW itself removed one in 8.0.1, and "
.. "modern Classic offers these same two."
+ 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."
+
OPTIONS_PAGE_ROWS = {
Controls = {
"RowStickyTarget", "RowBlockTrades", "RowAutoLoot", "RowInvertMouse",
@@ -689,7 +694,7 @@
},
Graphics = {
"RowUiScale", "RowFarclip", "RowWorldDetail",
- "RowDisplayMode", "RowVerticalSync", "RowRenderScale",
+ "RowDisplayMode", "RowVerticalSync", "RowWorldShadows", "RowRenderScale",
},
Nameplates = {
"RowEnemyPlates", "RowFriendlyPlates",
@@ -2517,11 +2522,21 @@
"OPTION_TOOLTIP_VERTICAL_SYNC")
-
+
+
+ OptionsRow_OnLoad(self, "worldShadows", "Real Character Shadows",
+ "BENILLA_TOOLTIP_WORLD_SHADOWS")
+
+
+
+
+
+
+
-- Render Scale (decision 1639) -- benilla's own
-- row, no 1.12 counterpart: the reference renders
diff --git a/crates/benilla-app/src/blob_shadow.rs b/crates/benilla-app/src/blob_shadow.rs
index 6070f668..953f15d8 100644
--- a/crates/benilla-app/src/blob_shadow.rs
+++ b/crates/benilla-app/src/blob_shadow.rs
@@ -182,6 +182,7 @@ fn sync_shadows(
/// reaches zero, or no receiving surface is in the box (the reference's no-ground gate).
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn update_shadows(
+ video: Res,
time: Res,
catalog: Option>,
rig: Option>,
@@ -219,6 +220,19 @@ 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 {
+ for (_, mut key, mut verts) in &mut shadows {
+ if key.shown || !verts.0.is_empty() {
+ hide(&mut key, &mut verts);
+ }
+ }
+ return;
+ }
let now = time.elapsed_secs();
let census = now >= *census_at;
if census {
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index ef892c33..f05cfccf 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -585,6 +585,9 @@ 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.
+ same("worldShadows", "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
@@ -1064,6 +1067,7 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
// Vertical Sync — a flag like every other checkbox here. `video::apply_present_mode`
// 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,
// 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.
@@ -1366,7 +1370,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
- let session: [(&str, String); 44] = [
+ let session: [(&str, String); 45] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -1412,6 +1416,7 @@ fn sync_cvars(
("minimapZoom", minimap.outdoor.to_string()),
("minimapInsideZoom", minimap.inside.to_string()),
("gxVSync", flag(video.vsync)),
+ ("worldShadows", flag(video.world_shadows)),
// The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state.
(
"gxWindow",
@@ -1821,6 +1826,10 @@ mod tests {
// VSync welds to the video knob, which in turn welds to the window literal's boot
// mode (`video::tests`) — so the registered "1" cannot drift from what we ship.
assert_eq!(d["gxVSync"] != 0.0, VideoConfig::default().vsync);
+ assert_eq!(
+ d["worldShadows"] != 0.0,
+ VideoConfig::default().world_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 f4071b98..1db62a0b 100644
--- a/crates/benilla-app/src/lib.rs
+++ b/crates/benilla-app/src/lib.rs
@@ -93,6 +93,7 @@ mod pending_item_ops;
mod perf;
mod pipe_warm;
mod player;
+mod player_shadow;
mod poi_marker;
mod portrait;
#[cfg(feature = "dev")]
@@ -525,7 +526,10 @@ pub fn run(build: BuildId) -> AppExit {
.add_plugins(CreatureAnimPlugin)
// The unit blob shadow: the dark ground oval under every unit, sized from the playing
// animation's box (the byte-verified law — wow-re unit-blob-shadow RE), on the same
- // surface-decal projector as the selection ring.
+ // surface-decal projector as the selection ring. `worldShadows 0` — the shipped default,
+ // the faithful 1.12 frame — draws it exactly as the reference does; while `worldShadows 1`
+ // the realtime shadow-map path owns unit shadows and this lane hides (the gate inside
+ // `update_shadows`), so a unit never wears the oval underneath its cast shadow.
.add_plugins(BlobShadowPlugin)
// Footprint decals (B212, decision 1006): the prints a walking unit leaves on snow/sand,
// spawn-once projections on the same decal projector, fading off the effect stream.
@@ -612,6 +616,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)
// 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/player_shadow.rs b/crates/benilla-app/src/player_shadow.rs
new file mode 100644
index 00000000..01bcbf23
--- /dev/null
+++ b/crates/benilla-app/src/player_shadow.rs
@@ -0,0 +1,653 @@
+//! Isolated real shadow-map support for the world.
+//!
+//! 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.
+
+use bevy::asset::RenderAssetUsages;
+use bevy::camera::visibility::{NoFrustumCulling, RenderLayers};
+use bevy::light::{
+ CascadeShadowConfigBuilder, DirectionalLight, DirectionalLightShadowMap, NotShadowReceiver,
+};
+use bevy::mesh::{Indices, PrimitiveTopology};
+use bevy::pbr::{Material, MaterialPipeline, MaterialPlugin, MeshMaterial3d};
+use bevy::prelude::*;
+use bevy::reflect::TypePath;
+use bevy::render::render_resource::{
+ AsBindGroup, RenderPipelineDescriptor, SpecializedMeshPipelineError,
+};
+use bevy::shader::ShaderRef;
+
+use benilla_assets::coords::wow_to_bevy;
+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::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 — in light space a caster's footprint sits exactly where
+/// its shadow falls, so an out-of-frustum tree still darkens ground the player is looking at.
+/// See [`static_collection_reach`] for the collection law.
+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;
+
+/// 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
+/// collection hunts for shadow SOURCES.
+const MAX_CASTER_HEIGHT: f32 = 35.0;
+
+/// Ceiling on the shadow-reach extension: the lighting sun's elevation only spans ~20°–37°
+/// over the whole day, but a degenerate near-horizontal direction must not explode the
+/// collection radius (and with it the rebuild cost) unbounded.
+const SHADOW_REACH_CAP: f32 = 120.0;
+
+/// How far past a caster's own position its shadow can land: `height × horizontal/vertical`
+/// of the lighting sun's travel direction. At the night elevation (~20°) a 35-unit tree throws
+/// a ~96-unit shadow — collecting only to `CASTER_RANGE + STATIC_REBUILD_STEP` made exactly
+/// those trees pop in as the camera drifted a rebuild step, their long shadows sweeping across
+/// ground the player was already looking at.
+fn shadow_reach_extension(sun_travel: Vec3) -> f32 {
+ // `sun_travel` is the direction the light TRAVELS (downward): -y is the vertical drop.
+ let down = -sun_travel.y;
+ if down <= 1e-3 {
+ return SHADOW_REACH_CAP;
+ }
+ let horizontal = Vec3::new(sun_travel.x, 0.0, sun_travel.z).length();
+ (MAX_CASTER_HEIGHT * horizontal / down).min(SHADOW_REACH_CAP)
+}
+
+/// 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.
+fn static_collection_reach(sun_travel: Vec3) -> f32 {
+ CASTER_RANGE + STATIC_REBUILD_STEP + shadow_reach_extension(sun_travel)
+}
+
+/// `WOW_SHADOW_TRACE=1` — log the caster population whenever it CHANGES, so "the shadow
+/// vanished when I turned" can be attributed to a lane: a dynamic-mesh count that dips with
+/// 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 {
+ 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"))
+}
+
+/// How far the LIVE sun may drift from the direction the shadow light was last given before
+/// the light snaps to it — about one game minute of lighting-sun travel (the DayNight phi
+/// table sweeps ~0.297 rad over 360 game minutes ≈ 8.25e-4 rad/min).
+///
+/// The shadow basis is quantised because a light that rotates EVERY frame defeats the cascade
+/// fit's texel snap: `calculate_cascade` (bevy_light) snaps the ortho origin to texel
+/// multiples in LIGHT space, which pins shadow edges to world positions under camera
+/// translation — but rotating the basis re-maps every world position, and at WoW map
+/// coordinates (Elwynn sits ~9,300 units from the world origin) even the lighting sun's tiny
+/// ~1.4e-5 rad/s drift slides light-space positions ~0.13 units/s ≈ 1.6 shadow texels/s: a
+/// continuous sub-texel re-rasterisation crawl on every shadow edge (the inn-roof field
+/// report). Held between snaps, the basis is bit-stable and the map only re-rasterises when
+/// the sun has honestly moved — a discrete 1–2 texel tick roughly once a game minute, the
+/// same physical reasoning that lets MCSH ship as a static bake. LIGHTING stays continuous;
+/// only the shadow basis steps.
+const SUN_SNAP_RADIANS: f32 = 8.0e-4;
+
+/// Whether the live sun direction has drifted far enough from the held (last-written) one to
+/// re-aim the shadow light. `None` = never written (first frame after spawn, or re-enable
+/// after teardown): always due.
+fn sun_snap_due(held: Option, live: Vec3) -> bool {
+ match held {
+ None => true,
+ Some(held) => held.angle_between(live) > SUN_SNAP_RADIANS,
+ }
+}
+
+#[derive(Component)]
+struct WorldShadowCaster;
+
+#[derive(Component)]
+struct WorldShadowSun;
+
+#[derive(Component)]
+struct WorldShadowCameraLayer {
+ had_layers: bool,
+}
+
+#[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,
+ 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.
+ sun_written: Option,
+ dynamic_mesh: Option>,
+ material: 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),
+}
+
+pub(crate) struct WorldShadowPlugin;
+
+/// 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.
+#[derive(Asset, AsBindGroup, TypePath, Debug, Clone, Default)]
+struct ShadowCasterMaterial {}
+
+impl Material for ShadowCasterMaterial {
+ fn fragment_shader() -> ShaderRef {
+ "embedded://benilla_app/shaders/shadow_caster.wgsl".into()
+ }
+
+ fn specialize(
+ _pipeline: &MaterialPipeline,
+ descriptor: &mut RenderPipelineDescriptor,
+ _layout: &bevy::mesh::MeshVertexBufferLayoutRef,
+ _key: bevy::pbr::MaterialPipelineKey,
+ ) -> Result<(), SpecializedMeshPipelineError> {
+ // WoW's model batches are frequently authored as two-sided geometry. Keeping both faces
+ // in the caster avoids holes in the projected silhouette when a batch has mixed winding.
+ descriptor.primitive.cull_mode = None;
+ Ok(())
+ }
+}
+
+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);
+ }
+}
+
+fn update_world_shadow(
+ state: Res>,
+ video: Res,
+ lighting: Res,
+ mut runtime: ResMut,
+ mut commands: Commands,
+ mut meshes: ResMut>,
+ mut materials: ResMut>,
+ parts: Query<
+ (
+ &PickMesh,
+ &ModelPart,
+ Option<&GlobalTransform>,
+ Option<&RigPart>,
+ &ShadowOccluder,
+ Option<&MeshMaterial3d>,
+ ),
+ Without,
+ >,
+ rigs: Query<&RigSkin>,
+ palettes: Res,
+ mut cameras: Query<
+ (
+ Entity,
+ Option<&mut RenderLayers>,
+ Option<&WorldShadowCameraLayer>,
+ ),
+ With,
+ >,
+ mut suns: Query<&mut Transform, With>,
+ cameras_for_position: Query<&GlobalTransform, With>,
+) {
+ let wanted = video.world_shadows && *state.get() == ClientState::InWorld;
+ if !wanted {
+ if let Some(entity) = runtime.dynamic_caster.take() {
+ commands.entity(entity).despawn();
+ }
+ if let Some(entity) = runtime.sun.take() {
+ commands.entity(entity).despawn();
+ }
+ // Forget the held shadow basis with its light, so a re-enable aims the fresh sun on
+ // its first frame rather than waiting out a snap threshold.
+ runtime.sun_written = None;
+ if let Some(handle) = runtime.dynamic_mesh.take() {
+ meshes.remove(handle.id());
+ }
+ if let Some(handle) = runtime.material.take() {
+ materials.remove(handle.id());
+ }
+ for (entity, layers, marker) in &mut cameras {
+ let Some(marker) = marker else { continue };
+ if marker.had_layers {
+ if let Some(mut layers) = layers {
+ *layers = layers.clone().without(PLAYER_SHADOW_LAYER);
+ }
+ } else {
+ commands.entity(entity).remove::();
+ }
+ commands.entity(entity).remove::();
+ }
+ 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);
+ let sun = commands
+ .spawn((
+ DirectionalLight {
+ // The light exists to populate a shadow map. WoW's existing shader lighting
+ // remains authoritative, so this light must not add a second diffuse term.
+ illuminance: 0.0,
+ 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`.
+ num_cascades: 1,
+ minimum_distance: 0.1,
+ maximum_distance: CASTER_RANGE,
+ ..default()
+ }
+ .build(),
+ Transform::IDENTITY,
+ Visibility::Visible,
+ RenderLayers::layer(PLAYER_SHADOW_LAYER),
+ WorldShadowSun,
+ ))
+ .id();
+ runtime.dynamic_caster = Some(dynamic_caster);
+ runtime.sun = Some(sun);
+ runtime.dynamic_mesh = Some(dynamic_mesh);
+ runtime.material = Some(material);
+ }
+
+ for (entity, layers, marker) in &mut cameras {
+ if marker.is_some() {
+ if let Some(mut layers) = layers {
+ *layers = layers.clone().with(PLAYER_SHADOW_LAYER);
+ }
+ } else if let Some(mut layers) = layers {
+ *layers = layers.clone().with(PLAYER_SHADOW_LAYER);
+ commands
+ .entity(entity)
+ .insert(WorldShadowCameraLayer { had_layers: true });
+ } else {
+ commands.entity(entity).insert((
+ RenderLayers::default().with(PLAYER_SHADOW_LAYER),
+ WorldShadowCameraLayer { had_layers: false },
+ ));
+ }
+ }
+
+ let light_position = cameras_for_position
+ .iter()
+ .next()
+ .map(GlobalTransform::translation)
+ .unwrap_or(Vec3::ZERO);
+
+ let sun_direction = if lighting.sun_dir.length_squared() > 1e-6 {
+ lighting.sun_dir.normalize()
+ } 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.
+ 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.
+ 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(
+ &parts,
+ &rigs,
+ &palettes,
+ light_position,
+ entity_reach,
+ tall_reach,
+ &mut positions,
+ &mut indices,
+ );
+ let dynamic_tris = (indices.len() / 3) as u32;
+ restore_mesh_buffers(mesh, positions, indices);
+ if shadow_trace() {
+ let now = (dynamic_tris, admitted, rejected);
+ if runtime.traced != now {
+ info!(
+ "shadow-trace: dynamic {} tris | parts admitted {} / occluder-rejected {} (was {:?})",
+ dynamic_tris, admitted, rejected, runtime.traced
+ );
+ runtime.traced = now;
+ }
+ }
+ }
+ }
+
+ 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);
+ runtime.sun_written = Some(sun_direction);
+ }
+ }
+ }
+}
+
+/// 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.
+fn append_triangles(source: &[u32], vertex_count: u32, base: u32, indices: &mut Vec) {
+ for triangle in source.chunks_exact(3) {
+ if triangle.iter().all(|index| *index < vertex_count) {
+ indices.extend(triangle.iter().map(|index| base + *index));
+ }
+ }
+}
+
+/// 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) {
+ let mut positions = match mesh.remove_attribute(Mesh::ATTRIBUTE_POSITION) {
+ Some(bevy::mesh::VertexAttributeValues::Float32x3(values)) => values,
+ _ => Vec::new(),
+ };
+ let mut indices = match mesh.remove_indices() {
+ Some(Indices::U32(values)) => values,
+ _ => Vec::new(),
+ };
+ positions.clear();
+ indices.clear();
+ (positions, indices)
+}
+
+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 {
+ let mut mesh = Mesh::new(
+ PrimitiveTopology::TriangleList,
+ RenderAssetUsages::default(),
+ );
+ mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, Vec::<[f32; 3]>::new());
+ mesh.insert_indices(Indices::U32(Vec::new()));
+ mesh
+}
+
+fn collect_world_geometry(
+ parts: &Query<
+ (
+ &PickMesh,
+ &ModelPart,
+ Option<&GlobalTransform>,
+ Option<&RigPart>,
+ &ShadowOccluder,
+ Option<&MeshMaterial3d>,
+ ),
+ Without,
+ >,
+ rigs: &Query<&RigSkin>,
+ palettes: &RigPalettes,
+ light_position: Vec3,
+ creature_reach: f32,
+ doodad_reach: f32,
+ positions: &mut Vec<[f32; 3]>,
+ indices: &mut Vec,
+) -> (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) {
+ continue;
+ }
+ // NOT `InheritedVisibility`: that folds the exterior window gate, the portal PVS and
+ // the exterior-scene cull — all camera-orientation verdicts — and a tree behind the
+ // camera still blocks the sun. `ShadowOccluder` is the content half of the same law
+ // (toggles, far clip, distance fade, material alpha), written by the one visibility
+ // authority.
+ if !occluder.0 {
+ rejected += 1;
+ 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.
+ let reach = match part.kind {
+ ModelKind::Creature | ModelKind::GameObject => creature_reach,
+ ModelKind::Doodad | ModelKind::Wmo => doodad_reach,
+ };
+ // A rig part's placement lives in its palette (world-space rows), not necessarily a
+ // propagated `GlobalTransform`; prefer the transform when present, else the rig's
+ // slot origin — both are one lookup, never a skin.
+ let anchor = global.map(GlobalTransform::translation).or_else(|| {
+ rig_part
+ .and_then(|rig_part| rigs.get(rig_part.0).ok())
+ .and_then(|rig| palettes.slot_origin(rig.slot))
+ });
+ let Some(anchor) = anchor else { continue };
+ if anchor.distance_squared(light_position) > reach * reach {
+ continue;
+ }
+ admitted += 1;
+ let base = positions.len() as u32;
+ if let Some(rig_part) = rig_part {
+ append_skinned(pick, rig_part, rigs, palettes, positions);
+ } else if let Some(global) = global {
+ positions.extend(
+ pick.0
+ .positions
+ .iter()
+ .map(|p| global.transform_point(wow_to_bevy(*p)).to_array()),
+ );
+ }
+ let added = positions.len() as u32 - base;
+ if added != 0 {
+ append_triangles(&pick.0.indices, added, base, indices);
+ }
+ }
+ (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)
+}
+
+fn append_skinned(
+ pick: &PickMesh,
+ rig_part: &RigPart,
+ rigs: &Query<&RigSkin>,
+ palettes: &RigPalettes,
+ positions: &mut Vec<[f32; 3]>,
+) {
+ let Ok(rig) = rigs.get(rig_part.0) else {
+ return;
+ };
+ let Some(palette) = palettes.world_palette(rig.slot, rig.bones() as usize) else {
+ return;
+ };
+ for (index, position) in pick.0.positions.iter().enumerate() {
+ // A vertex without skinning data still gets an entry: skipping it would shorten
+ // `positions` against the index buffer and rewire every later triangle in the
+ // submesh. Seat it on the root matrix so it at least stays near the model.
+ let (Some(joints), Some(weights)) = (pick.0.joints.get(index), pick.0.weights.get(index))
+ else {
+ let fallback = palette
+ .get(0)
+ .map(|matrix| matrix.transform_point3(wow_to_bevy(*position)))
+ .unwrap_or(Vec3::ZERO);
+ positions.push(fallback.to_array());
+ continue;
+ };
+ let mut skinned = Vec3::ZERO;
+ for lane in 0..4 {
+ if weights[lane] > 0.0 {
+ if let Some(matrix) = palette.get(joints[lane] as usize) {
+ skinned += matrix.transform_point3(wow_to_bevy(*position)) * weights[lane];
+ }
+ }
+ }
+ positions.push(skinned.to_array());
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{
+ append_triangles, casts_realtime_shadow, shadow_reach_extension, static_collection_reach,
+ sun_snap_due, CASTER_RANGE, MAX_CASTER_HEIGHT, SHADOW_REACH_CAP, STATIC_REBUILD_STEP,
+ SUN_SNAP_RADIANS,
+ };
+ use benilla_formats::ModelBlend;
+ use benilla_world::model_render::ModelKind;
+ 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
+ ));
+
+ for kind in [ModelKind::GameObject, ModelKind::Doodad, ModelKind::Wmo] {
+ assert!(!casts_realtime_shadow(kind, ModelBlend::Opaque));
+ assert!(!casts_realtime_shadow(kind, ModelBlend::AlphaTest));
+ }
+ }
+
+ #[test]
+ fn the_shadow_sun_snaps_on_first_write_holds_under_drift_and_rearms() {
+ let held = Vec3::new(0.3, -0.8, 0.52).normalize();
+ // Never written (spawn, or re-enable after teardown): always due.
+ assert!(sun_snap_due(None, held), "first write always fires");
+ // Sub-threshold drift holds the basis bit-stable — this is the whole point.
+ let axis = Vec3::Y;
+ let small = bevy::prelude::Quat::from_axis_angle(axis, SUN_SNAP_RADIANS * 0.5) * held;
+ assert!(
+ !sun_snap_due(Some(held), small),
+ "sub-threshold drift holds"
+ );
+ // Past the threshold the light snaps…
+ let large = bevy::prelude::Quat::from_axis_angle(axis, SUN_SNAP_RADIANS * 2.0) * held;
+ assert!(sun_snap_due(Some(held), large), "over-threshold snaps");
+ // …and the new held value re-arms the window around the NEW direction.
+ assert!(
+ !sun_snap_due(Some(large), large),
+ "identical direction never rewrites"
+ );
+ let drift_from_new =
+ bevy::prelude::Quat::from_axis_angle(axis, SUN_SNAP_RADIANS * 0.5) * large;
+ assert!(
+ !sun_snap_due(Some(large), drift_from_new),
+ "window re-arms around the snapped direction"
+ );
+ }
+
+ #[test]
+ fn shadow_reach_follows_the_sun_and_is_capped() {
+ // 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.
+ 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.
+ 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.
+ assert!(static_collection_reach(Vec3::NEG_Y) >= CASTER_RANGE + STATIC_REBUILD_STEP);
+ }
+
+ #[test]
+ fn an_out_of_range_index_drops_its_whole_triangle_and_no_others() {
+ let mut out = Vec::new();
+ // Middle triangle references vertex 9 of 3 — the whole triple must go, and the last
+ // triangle must keep its own vertices (a single dropped index would shift it).
+ append_triangles(&[0, 1, 2, 1, 2, 9, 2, 0, 1], 3, 10, &mut out);
+ assert_eq!(out, vec![10, 11, 12, 12, 10, 11]);
+ }
+
+ #[test]
+ fn a_trailing_partial_triple_is_ignored() {
+ let mut out = Vec::new();
+ append_triangles(&[0, 1, 2, 0, 1], 3, 0, &mut out);
+ assert_eq!(out, vec![0, 1, 2]);
+ }
+}
diff --git a/crates/benilla-app/src/shaders.rs b/crates/benilla-app/src/shaders.rs
index 07eb4b88..f4b4b473 100644
--- a/crates/benilla-app/src/shaders.rs
+++ b/crates/benilla-app/src/shaders.rs
@@ -24,6 +24,7 @@ pub(crate) fn plugin(app: &mut App) {
bevy::asset::embedded_asset!(app, "shaders/ui_gamma.wgsl");
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");
}
#[cfg(test)]
@@ -59,6 +60,6 @@ mod tests {
`shaders::plugin`, or was registered from a file that is not directly under src/"
);
}
- assert_eq!(found, 5, "the game's shader set changed size");
+ assert_eq!(found, 6, "the game's shader set changed size");
}
}
diff --git a/crates/benilla-app/src/shaders/shadow_caster.wgsl b/crates/benilla-app/src/shaders/shadow_caster.wgsl
new file mode 100644
index 00000000..47dedcc1
--- /dev/null
+++ b/crates/benilla-app/src/shaders/shadow_caster.wgsl
@@ -0,0 +1,7 @@
+// The proxy is deliberately invisible in the camera's forward pass. Its material remains
+// opaque, so Bevy still queues it for the depth-only directional-light shadow pass.
+@fragment
+fn fragment() -> @location(0) vec4 {
+ discard;
+ return vec4(0.0);
+}
diff --git a/crates/benilla-app/src/ui_script/options_tests.rs b/crates/benilla-app/src/ui_script/options_tests.rs
index c38de37e..0f809e7f 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_WORLD_SHADOWS", "GraphicsRowWorldShadows"),
];
if let Some((_, want_row)) = BENILLA_OWNED.iter().find(|(k, _)| *k == key) {
assert_eq!(row, *want_row, "{row}: not this row's string");
@@ -2228,7 +2229,7 @@ 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, 58, "every tipped row carries a live key");
+ assert_eq!(checked, 59, "every tipped row carries a live key");
assert_eq!(
untipped,
vec![
@@ -2338,7 +2339,7 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
// rather than 1.12 GlobalStrings — see the guard above.
// …and Block Trades (1764), the Controls page's 28th CVar row, and Enable Error Speech
// (1815), the Audio page's fourth checkbox and 1.12's own.
- assert_eq!(raised, 58, "every row but Auto Loot raises a description");
+ assert_eq!(raised, 59, "every row but Auto Loot raises a description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
diff --git a/crates/benilla-app/src/video.rs b/crates/benilla-app/src/video.rs
index 95f810d1..92de116d 100644
--- a/crates/benilla-app/src/video.rs
+++ b/crates/benilla-app/src/video.rs
@@ -215,6 +215,8 @@ 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.
+ pub(crate) world_shadows: bool,
pub(crate) display: DisplayMode,
/// The windowed size, `gxResolution`. Kept while fullscreen so leaving it can restore it.
pub(crate) windowed: UVec2,
@@ -224,6 +226,7 @@ impl Default for VideoConfig {
fn default() -> Self {
Self {
vsync: !novsync_env(),
+ world_shadows: false,
display: if windowed_env() {
DisplayMode::Windowed
} else {
@@ -534,6 +537,7 @@ mod tests {
let mut app = App::new();
app.insert_resource(VideoConfig {
vsync: true,
+ world_shadows: false,
display: DisplayMode::Fullscreen,
windowed: UVec2::new(1024, 768),
})
diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs
index db9859a5..8d09ad38 100644
--- a/crates/benilla-assets/src/materials.rs
+++ b/crates/benilla-assets/src/materials.rs
@@ -253,6 +253,12 @@ impl MaterialExtension for WowModelExt {
// at 10/11. Bevy's own SKINNED branch never fires for these meshes — that's the point.
if layout.0.contains(crate::ATTRIBUTE_WOW_JOINT_INDEX) {
descriptor.vertex.shader_defs.push("WOW_RIG_SKIN".into());
+ // The FRAGMENT stage keys on the same def: a skinned unit takes the whole-unit
+ // ground-point shadow sample (one fetch at its rig origin) instead of the
+ // per-fragment fetch, which would read the unit's own caster copy as an occluder.
+ if let Some(fragment) = descriptor.fragment.as_mut() {
+ fragment.shader_defs.push("WOW_RIG_SKIN".into());
+ }
let mut attrs = Vec::with_capacity(7);
for (attr, loc) in [
(Mesh::ATTRIBUTE_POSITION, 0),
diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl
index e5ce32c2..9c7fc949 100644
--- a/crates/benilla-assets/src/shaders/terrain.wgsl
+++ b/crates/benilla-assets/src/shaders/terrain.wgsl
@@ -29,7 +29,8 @@
mesh_functions,
forward_io::Vertex,
view_transformations::position_world_to_clip,
- mesh_view_bindings::view,
+ mesh_view_bindings::{lights, view},
+ shadows,
}
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var layer_array: texture_2d_array;
@@ -37,6 +38,9 @@
@group(#{MATERIAL_BIND_GROUP}) @binding(105) var splat_samp: sampler;
@group(#{MATERIAL_BIND_GROUP}) @binding(110) var shadow_array: texture_2d_array;
+// A fully covered character shadow retains 45% of the authored terrain colour.
+const SHADOW_SUN_FLOOR: f32 = 0.45;
+
// Per-tile Vec4 uniforms packed into ONE buffer (binding 106) — the field order here MUST match the
// Rust `TerrainExtension` struct. Light + fog live in the shared global-light storage buffer (below);
// what's left is just the per-tile layer tiling factor.
@@ -89,6 +93,9 @@ struct TerrainVsOut {
// hard knee, the director's "hard square". Sun N·L moving per-pixel → per-vertex is the
// faithful direction (Step 3's own note: indistinguishable on the smooth term).
@location(6) primary: vec3,
+ // The receiver normal is carried so the real cascaded shadow lookup can apply a slope-aware
+ // bias. This is forward-pass data only; the terrain shadow caster is a stock Bevy proxy.
+ @location(7) world_normal: vec3,
}
// Terrain's point-light **candidacy half-width** (yd): the reference's guaranteed covered box is
@@ -196,6 +203,7 @@ fn vertex(in: Vertex) -> TerrainVsOut {
mesh_functions::mesh_position_local_to_world(world_from_local, vec4(in.position, 1.0));
out.clip_position = position_world_to_clip(out.world_position.xyz);
let world_normal = mesh_functions::mesh_normal_local_to_world(in.normal, in.instance_index);
+ out.world_normal = world_normal;
out.uv = in.uv;
out.uv_b = in.uv_b;
out.color = in.color;
@@ -216,8 +224,9 @@ fn vertex(in: Vertex) -> TerrainVsOut {
// byte-verified `1/(0.7d + 0.03d²)`, raw over-gamut colours in.
let ndotl = max(dot(n, l), 0.0);
let points = point_light_sum(out.world_position.xyz, n, mcnk_cell_anchor(out.world_position.xyz));
+ let sun_lighting = wow_light.light_diffuse.rgb * ndotl;
out.primary = clamp(
- wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb * ndotl + points,
+ wow_light.light_ambient.rgb + sun_lighting + points,
vec3(0.0),
vec3(1.0),
);
@@ -281,6 +290,28 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 {
// colour, byte-faithful to GL T&L (see the `primary` struct note; the MCNR normal and the
// DayNight sun share a space per the Phase-0 validation). The MCSH ×(0.3·s + 0.7) factor below
// still scales the whole modulate, as the traced combine does.
+ // Fetch the character coverage here, but apply it as a NEUTRAL scalar after the terrain's
+ // authored lighting below. Subtracting the warm sun RGB left only Elwynn's green ambient and
+ // produced a green silhouette on yellow morning ground. The era-style projected shadow is a
+ // greyscale attenuation: it darkens the existing hue instead of changing it.
+ // Keep the raw fetch for the specular gate below. The realtime map now contains CHARACTERS
+ // only; MCSH contains the static world's baked blockers. They are independent occluders, so
+ // the character map must never replace (and thereby brighten) the authored terrain bake.
+ var world_shadow = 1.0;
+ if (lights.n_directional_lights > 0u) {
+ let view_z = (view.view_from_world * in.world_position).z;
+ for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
+ if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
+ world_shadow = shadows::fetch_directional_shadow(
+ light_id,
+ in.world_position,
+ normalize(in.world_normal),
+ view_z,
+ );
+ break;
+ }
+ }
+ }
let primary = in.primary;
// STEP 4: MCSH baked shadow. On the reference path (pixelShaders+specular) terrain is ONE pass
@@ -294,14 +325,20 @@ 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;
+ let spec_gate = min(shadow_lit, world_shadow);
+ let character_shadow_term = mix(SHADOW_SUN_FLOOR, 1.0, world_shadow);
// The faithful `terrainp_s` combine (Q15):
// diffuse = tex · primary · (0.3·shadow + 0.7) → a flat −30% in shadow, NO colour tint
// specular = per-vertex sheen · gloss_mask · shadow → gated to ZERO in shadow (no sheen in shade)
// (`tex·primary` is the MODULATE-1× diffuse; the sheen is added after, separate-specular.) Then
// LDR-clamp; gamma/byte throughout; raw gamma out (GAMMA LANE, 0161).
- let diffuse_term = color * primary * (0.3 * shadow_lit + 0.7);
- let spec_term = in.specular * specmask * shadow_lit;
+ let diffuse_term = color * primary * (0.3 * shadow_lit_eff + 0.7) * character_shadow_term;
+ let spec_term = in.specular * specmask * spec_gate;
var tuned = clamp(diffuse_term + spec_term, vec3(0.0), vec3(1.0));
// STEP 5: gamma-space linear fog (q6). Applied AFTER tone/curve (so the diagnostic knobs see
diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl
index c5964ed2..8ac68859 100644
--- a/crates/benilla-assets/src/shaders/wow_model.wgsl
+++ b/crates/benilla-assets/src/shaders/wow_model.wgsl
@@ -47,7 +47,8 @@
pbr_functions::alpha_discard,
pbr_bindings,
forward_io::VertexOutput,
- mesh_view_bindings::view,
+ mesh_view_bindings::{lights, view},
+ shadows,
mesh_functions,
}
@@ -111,6 +112,9 @@ struct ModelParams {
};
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var m: ModelParams;
+// A fully covered character shadow retains 45% of the authored model lighting.
+const SHADOW_SUN_FLOOR: f32 = 0.45;
+
// The shared global light (lighting::global_light): ONE storage buffer every material reads, updated
// once/frame in place — replaces the per-material light/fog uniforms the old apply_wow_lighting re-pushed
// each frame. The model reads rows 0-2 (ambient/diffuse/sun) + fog, plus rows 6-12: the disassembled
@@ -972,6 +976,63 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// through `point_diffuse` below like any exterior entity.
let is_rig = m.sun_scale.x >= 1.5;
let lit = select(select(lit_exterior, lit_interior, is_interior), lit_m2_interior, is_rig);
+ var player_shadow = 1.0;
+ if (lights.n_directional_lights > 0u) {
+ let view_z = (view.view_from_world * in.world_position).z;
+ for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
+ if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
+#ifdef WOW_RIG_SKIN
+ // A skinned UNIT samples the map ONCE at its rig origin (between the feet),
+ // nudged 2.5 units TOWARD the sun, and the whole unit dims uniformly — the
+ // reference's own per-unit response. Per-fragment sampling is wrong here: the
+ // caster proxy holds a CPU-skinned copy of this very body, so a body fragment
+ // reads its own limbs/weapon as occluders (self-shadow speckle no bias fixes on
+ // animated silhouettes). Points along the sun ray share a shadow-map texel, so
+ // the nudge only excludes occluders within ~2.5 units of the feet ALONG the ray
+ // (a fence hugging the unit) — a building or canopy still lands. Gated on the
+ // directional-enable flag so an interior-lit unit never takes an exterior dim.
+ if (wow_light.light_sun.w > 0.5) {
+ let sun_ray = normalize(wow_light.light_sun.xyz);
+ let anchor = wow_light.rig_origin[(fade_tag >> 19u) & 0x7ffu].xyz
+ - 2.5 * sun_ray;
+ player_shadow = shadows::fetch_directional_shadow(
+ light_id,
+ vec4(anchor, 1.0),
+ vec3(0.0, 1.0, 0.0),
+ view_z,
+ );
+ }
+#else
+ player_shadow = shadows::fetch_directional_shadow(
+ light_id,
+ in.world_position,
+ wow_normalize(in.world_normal),
+ view_z,
+ );
+#endif
+ break;
+ }
+ }
+ }
+ // 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,
+ // point-lit props, and shadow-side characters globally too dark.
+ //
+ // `is_rig` (the glue create-booth lane) is excluded DELIBERATELY: a booth scene's light is
+ // entirely its authored rig — there is no world sun to block. World units are `ShadeSel::Lit`
+ // and receive through the WOW_RIG_SKIN ground sample above. The `player_shadow < 0.999`
+ // arm keeps `worldShadows 0` (and a fully sunlit fragment) byte-identical: `ambient +
+ // (lit − ambient) × 1.0` is not guaranteed to round back to `lit`.
+ let shadow_term = mix(SHADOW_SUN_FLOOR, 1.0, player_shadow);
+ let lit_with_shadow = select(
+ lit,
+ clamp(
+ wow_light.light_ambient.rgb + (lit - wow_light.light_ambient.rgb) * shadow_term,
+ vec3(0.0),
+ vec3(1.0),
+ ),
+ !is_interior && !is_rig && player_shadow < 0.999,
+ );
// Gamma-space albedo — the lane (0161): the buffer holds bytes, lighting math runs on the
// authored values. (The old fog_params.z linear-space A/B is dead — settled by the lane.)
// `m.tint` is the animated M2Color RGB (identity 1 for static batches) — the same per-batch
@@ -1028,7 +1089,7 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// MOCV multiplies the lit terms (GL_COLOR_MATERIAL) but NOT the emission terms — SIDN and
// the highlight add alongside, exactly the FFP's material-emission placement.
let primary = clamp(
- vc * (lit + point_diffuse) + sidn_e + vec3(highlight),
+ vc * (lit_with_shadow + point_diffuse) + sidn_e + vec3(highlight),
vec3(0.0),
vec3(1.0),
);
@@ -1058,7 +1119,7 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// writes a colour. (Left off the `is_wmo` branch on purpose: a WMO surface is not a CM2
// instance and has no tint slot of its own.)
let primary = clamp(
- inst_tint * (lit + point_diffuse) + sidn_e + vec3(highlight),
+ inst_tint * (lit_with_shadow + point_diffuse) + sidn_e + vec3(highlight),
vec3(0.0),
vec3(1.0),
);
@@ -1068,7 +1129,7 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// (`sidn_e` is zero for every M2 batch; a WMO batch without MOCV lands here too and keeps it —
// harmlessly, since a WMO instance's tint slot is the identity slot 0.)
let primary = clamp(
- inst_tint * (lit + point_diffuse) + sidn_e + vec3(highlight),
+ inst_tint * (lit_with_shadow + point_diffuse) + sidn_e + vec3(highlight),
vec3(0.0),
vec3(1.0),
);
diff --git a/crates/benilla-world/src/model_render.rs b/crates/benilla-world/src/model_render.rs
index 3e4ebff2..d538fffa 100644
--- a/crates/benilla-world/src/model_render.rs
+++ b/crates/benilla-world/src/model_render.rs
@@ -1176,11 +1176,29 @@ pub enum ModelKind {
/// Tags every spawned model submesh with the metadata the panel toggles on: its subsystem and its
/// blend mode (the "layer" — opaque trunk vs alpha-cut canopy).
#[derive(Component, Clone, Copy)]
+#[require(ShadowOccluder)]
pub struct ModelPart {
pub kind: ModelKind,
pub blend: ModelBlend,
}
+/// The camera-INDEPENDENT half of [`visibility::apply_model_visibility`]'s verdict: does this
+/// submesh exist as world content this frame (toggles, far clip, distance fade, material alpha)?
+///
+/// `Visibility` cannot answer that for a shadow caster: it also folds the exterior window gate,
+/// the portal PVS and the exterior-scene cull — all functions of where the camera LOOKS — and a
+/// wall behind the camera still blocks the sun. Gating the caster proxy on `InheritedVisibility`
+/// made every entity-lane shadow swing with view direction. Defaults to occluding so a part casts
+/// correctly from its spawn frame; the walk corrects a toggled-off part one frame later.
+#[derive(Component, Clone, Copy)]
+pub struct ShadowOccluder(pub bool);
+
+impl Default for ShadowOccluder {
+ fn default() -> Self {
+ Self(true)
+ }
+}
+
/// Ordering handle so the one system allowed to *override* the model-`Visibility` authority — the
/// self-avatar first-person hide ([`crate::player`]) — can run **after** it and win the frame.
#[derive(SystemSet, Debug, Clone, Copy, PartialEq, Eq, Hash)]
diff --git a/crates/benilla-world/src/model_render/visibility.rs b/crates/benilla-world/src/model_render/visibility.rs
index a04c4bda..05559c2d 100644
--- a/crates/benilla-world/src/model_render/visibility.rs
+++ b/crates/benilla-world/src/model_render/visibility.rs
@@ -59,6 +59,7 @@ pub(super) fn apply_model_visibility(
&ModelPart,
&GlobalTransform,
&mut Visibility,
+ &mut super::ShadowOccluder,
Option<&DoodadFade>,
Option<&mut MeshTag>,
Option<&mut MeshMaterial3d>,
@@ -106,6 +107,7 @@ pub(super) fn apply_model_visibility(
part,
xf,
mut vis,
+ mut occluder,
fade,
tag,
mat,
@@ -241,6 +243,15 @@ pub(super) fn apply_model_visibility(
*vis = desired;
}
+ // The shadow-caster verdict: the CONTENT terms only. `portal_visible`, `exterior_ok`
+ // and `owner_hidden` are all functions of where the camera looks, and geometry the
+ // camera cannot see still blocks the sun — folding them in made entity-lane shadows
+ // swing with view direction. Change-gated like every write in this walk.
+ let occludes = toggled_on && in_range && fade_alpha > 0.0 && mat_factor > 0.0;
+ if occluder.0 != occludes {
+ occluder.0 = occludes;
+ }
+
// Push the fade alpha to the shader (per-instance `MeshTag` alpha field — `wow_model.wgsl`
// multiplies the cutout alpha by it) and swap to the blend material variant while feathering,
// back to the cutout once opaque. Write only on change so steady doodads (`fade == 1.0`,
diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl
index 37c500a8..d02a735e 100644
--- a/crates/benilla-world/src/shaders/static_gx.wgsl
+++ b/crates/benilla-world/src/shaders/static_gx.wgsl
@@ -27,9 +27,14 @@
// `Matte`). Output alpha is pinned 1.0 — every draw here is opaque-intent by admission
// (wow_model.wgsl's bit-3 armor, as a constant).
-#import bevy_render::view::View
+#import bevy_pbr::{
+ mesh_view_bindings::{lights, view},
+ shadows,
+}
-@group(0) @binding(0) var view: View;
+// Group 0 is Bevy's standard mesh-view bind group (view matrices, directional-light records and
+// the shadow textures the retained pass reads).
+const SHADOW_SUN_FLOOR: f32 = 0.45;
// The shared global light's per-frame PREFIX (lighting::global_light; the full layout lives in
// wow_model.wgsl — keep field order in sync with BOTH).
@@ -59,7 +64,7 @@ struct WowLight {
// rig/tint/mat-anim regions beyond it stay unmirrored (statics never read them).
prop_probes: array, 57344>,
}
-@group(0) @binding(1) var wow_light: WowLight;
+@group(2) @binding(0) var wow_light: WowLight;
// Per-cell state (static_gx/render.rs `cell_layout`).
struct GxCell {
@@ -281,6 +286,39 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 {
vec3(0.0),
vec3(1.0),
);
+ // The retained path uses the same Bevy directional shadow map as terrain and entity models.
+ // `static_gx` used to have no mesh-view shadow bindings, which made every Stormwind WMO act
+ // as if shadows were disabled even though the caster map was populated.
+ var world_shadow = 1.0;
+ if (lights.n_directional_lights > 0u && (in.word & WORD_INTERIOR) == 0u) {
+ let view_z = (view.view_from_world * in.world_position).z;
+ for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
+ if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
+ world_shadow = shadows::fetch_directional_shadow(
+ light_id,
+ in.world_position,
+ n_lit,
+ view_z,
+ );
+ break;
+ }
+ }
+ }
+ let shadow_term = mix(SHADOW_SUN_FLOOR, 1.0, world_shadow);
+ // Keep ambient energy when the realtime map blocks the sun. The retained pass also carries
+ // authored fixture/probe lighting, which must not be darkened by a directional shadow. The
+ // `world_shadow < 0.999` arm keeps `worldShadows 0` (no shadow-mapped light) and a fully
+ // sunlit fragment byte-identical: `ambient + (lit − ambient) × 1.0` is not guaranteed to
+ // round back to `lit`.
+ let lit_nl_shadowed = select(
+ lit_nl,
+ clamp(
+ wow_light.light_ambient.rgb + (lit_nl - wow_light.light_ambient.rgb) * shadow_term,
+ vec3(0.0),
+ vec3(1.0),
+ ),
+ world_shadow < 0.999,
+ );
// The order-2 SH basis products over the fragment normal — shared by the exterior
// doodad lobe and the interior-prop probe lane (wow_model.wgsl computes them once too).
let quad = vec4(n_lit.x * n_lit.y, n_lit.y * n_lit.z, n_lit.z * n_lit.z, n_lit.x * n_lit.z);
@@ -307,12 +345,14 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 {
// WINDOW (MOMT 0x20) — interior drawer only: GL_LIGHT0 swapped to the brighter
// Direct/Ambient midpoint pair, ambient +16/255 saturating (0x6d37e0).
let window_mid = 0.5 * (wow_light.light_ambient.rgb + wow_light.light_diffuse.rgb);
- let lit_window = clamp(
- window_mid + vec3(16.0 / 255.0) + window_mid * ndotl,
+ // The window's directional half rides the sun, so it takes the shadow term (×1.0 exact
+ // when the caster is off — no identity guard needed on a pure multiply).
+ let lit_window_shadowed = clamp(
+ window_mid + vec3(16.0 / 255.0) + window_mid * ndotl * shadow_term,
vec3(0.0),
vec3(1.0),
);
- let lit_int_base = select(lit_nl, lit_window, (in.word & WORD_WINDOW) != 0u);
+ let lit_int_base = select(lit_nl_shadowed, lit_window_shadowed, (in.word & WORD_WINDOW) != 0u);
// The interior BATCH-CLASS lanes (trace-forensics-abbey-interior-d3d §2): INT = unlit
// (the bake IS the room's light), TRANS = the per-vertex MOCV-alpha lit↔bake lerp,
// EXT = plain lit_nl. Exterior groups take lit_nl at sun-scale 1 (prog 198/VS 151 —
@@ -323,7 +363,11 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 {
} else if (!class_int) {
lit_wmo_interior = lit_int_base;
}
- let lit_wmo = select(lit_nl, lit_wmo_interior, interior);
+ // EXTERIOR surfaces are the ones the realtime map can shadow (the fetch above is
+ // gated on !interior, so `lit_nl_shadowed` collapses to `lit_nl` for interior
+ // fragments anyway). Taking plain `lit_nl` here left every Stormwind street and
+ // wall unshadowed while the caster map was fully populated.
+ let lit_wmo = select(lit_nl_shadowed, lit_wmo_interior, interior);
// SIDN night glow (MOMT 0x10): the authored emissive × the live night fraction, an
// EMISSION term — inside the clamped sum, never MOCV-multiplied; dead on the unlit
// INT lane, TRANS-weighted by the lit-pass alpha (wmo-interior-night-light §4).
@@ -421,9 +465,26 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 {
* (dot(wow_light.sh_c10_b.xyz, n_lit) + dot(wow_light.sh_c13_b, quad)
+ wow_light.sh_c16.z * x2y2),
);
- let lit_doodad = clamp(sun_lobe, vec3(0.0), vec3(1.0));
+ // The SH lobe's own ambient rows (the .w column) are the doodad lane's ambient share:
+ // subtract-and-restore around the shadow term so only the directional part of the lobe
+ // dims. Guarded like `lit_nl_shadowed` so the no-shadow state stays byte-identical.
+ let sun_ambient = vec3(
+ wow_light.sh_c10_r.w,
+ wow_light.sh_c10_g.w,
+ wow_light.sh_c10_b.w,
+ );
+ let lit_doodad_plain = clamp(sun_lobe, vec3(0.0), vec3(1.0));
+ let lit_doodad = select(
+ lit_doodad_plain,
+ clamp(
+ sun_ambient + (sun_lobe - sun_ambient) * shadow_term,
+ vec3(0.0),
+ vec3(1.0),
+ ),
+ world_shadow < 0.999,
+ );
// Sun disabled (light_sun.w) falls back to the FFP matte, like the entity path.
- let lit = select(lit_nl, lit_doodad, wow_light.light_sun.w > 0.5);
+ let lit = select(lit_nl_shadowed, lit_doodad, wow_light.light_sun.w > 0.5);
// FFP combine: the light sum saturates FIRST, the texture (× the baked constant
// tint, when authored) modulates the clamped result. Statics: inst_tint identity,
// no highlight, no SIDN.
diff --git a/crates/benilla-world/src/static_gx/mod.rs b/crates/benilla-world/src/static_gx/mod.rs
index 6880ad40..84f4af97 100644
--- a/crates/benilla-world/src/static_gx/mod.rs
+++ b/crates/benilla-world/src/static_gx/mod.rs
@@ -294,8 +294,11 @@ struct GxItem {
owner: (i32, i32),
texture: Option>,
/// Kept as a live handle so the render world's `GpuImage` can never be dropped from under
- /// the baked cell (the id alone holds nothing).
- _texture_handle: Option>,
+ /// the baked cell (the id alone holds nothing). Never READ (the bake keys everything off
+ /// [`Self::texture`]'s id) — its whole job is the strong reference held by its own
+ /// existence, so `dead_code` is a false positive here.
+ #[allow(dead_code)]
+ texture_handle: Option>,
cutout: bool,
two_sided: bool,
unlit: bool,
@@ -770,7 +773,7 @@ impl StaticGx {
local_aabb: b.aabb,
owner: b.owner,
texture: b.texture.as_ref().map(Handle::id),
- _texture_handle: b.texture,
+ texture_handle: b.texture,
cutout: b.blend == ModelBlend::AlphaTest && !crate::model_render::alphatest_disabled(),
two_sided: b.two_sided,
unlit: b.unlit,
diff --git a/crates/benilla-world/src/static_gx/render.rs b/crates/benilla-world/src/static_gx/render.rs
index 6d584b18..eab48cc2 100644
--- a/crates/benilla-world/src/static_gx/render.rs
+++ b/crates/benilla-world/src/static_gx/render.rs
@@ -16,9 +16,15 @@
//! bind groups, never a texture.
use bevy::camera::primitives::Aabb;
+use bevy::core_pipeline::oit::OrderIndependentTransparencySettingsOffset;
use bevy::ecs::query::QueryItem;
use bevy::image::Image;
use bevy::mesh::VertexBufferLayout;
+use bevy::pbr::{
+ MeshPipeline, MeshPipelineViewLayoutKey, MeshViewBindGroup, ViewEnvironmentMapUniformOffset,
+ ViewFogUniformOffset, ViewLightProbesUniformOffset, ViewLightsUniformOffset,
+ ViewScreenSpaceReflectionsUniformOffset,
+};
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use bevy::render::extract_component::{ExtractComponent, ExtractComponentPlugin};
@@ -30,14 +36,12 @@ use bevy::render::render_graph::{
NodeRunError, RenderGraphContext, RenderGraphExt, RenderLabel, ViewNode, ViewNodeRunner,
};
use bevy::render::render_resource::binding_types::{
- sampler, storage_buffer_read_only_sized, texture_2d_array, uniform_buffer, uniform_buffer_sized,
+ sampler, storage_buffer_read_only_sized, texture_2d_array, uniform_buffer_sized,
};
use bevy::render::render_resource::*;
use bevy::render::renderer::{RenderContext, RenderDevice, RenderQueue};
use bevy::render::texture::GpuImage;
-use bevy::render::view::{
- ExtractedView, Msaa, ViewDepthTexture, ViewTarget, ViewUniform, ViewUniformOffset, ViewUniforms,
-};
+use bevy::render::view::{ExtractedView, Msaa, ViewDepthTexture, ViewTarget, ViewUniformOffset};
use bevy::render::{Render, RenderSystems};
use bevy::shader::ShaderDefVal;
use std::ops::Range;
@@ -216,6 +220,7 @@ struct GxGpuCache {
struct GxPipelines {
view_layout: BindGroupLayoutDescriptor,
cell_layout: BindGroupLayoutDescriptor,
+ light_layout: BindGroupLayoutDescriptor,
sampler: Sampler,
sampler_clamp: Sampler,
/// Keyed `(cutout, two_sided)`; specialized for the world view's (samples, format) pair —
@@ -224,15 +229,25 @@ struct GxPipelines {
specialized_for: Option<(u32, TextureFormat)>,
}
-fn init_pipelines(mut commands: Commands, render_device: Res) {
- let view_layout = BindGroupLayoutDescriptor::new(
- "static_gx_view_layout",
+fn init_pipelines(
+ mut commands: Commands,
+ mesh_pipeline: Res,
+ render_device: Res,
+) {
+ // Use Bevy's real mesh-view layout for the retained pass. Besides the view and light records,
+ // this carries the directional shadow textures and comparison samplers produced for the
+ // world camera. The old two-binding layout could render the city, but it had no legal way for
+ // `static_gx.wgsl` to receive the shadow map.
+ let view_layout = mesh_pipeline
+ .get_view_layout(MeshPipelineViewLayoutKey::empty())
+ .main_layout
+ .clone();
+ // The pass's own extra (group 2): the shared light storage.
+ let light_layout = BindGroupLayoutDescriptor::new(
+ "static_gx_light_layout",
&BindGroupLayoutEntries::sequential(
ShaderStages::VERTEX_FRAGMENT,
- (
- uniform_buffer::(true),
- storage_buffer_read_only_sized(false, None),
- ),
+ (storage_buffer_read_only_sized(false, None),),
),
);
let cell_layout = BindGroupLayoutDescriptor::new(
@@ -276,6 +291,7 @@ fn init_pipelines(mut commands: Commands, render_device: Res) {
commands.insert_resource(GxPipelines {
view_layout,
cell_layout,
+ light_layout,
sampler: make("static_gx_repeat", AddressMode::Repeat),
sampler_clamp: make("static_gx_clamp", AddressMode::ClampToEdge),
pipelines: HashMap::default(),
@@ -347,6 +363,7 @@ fn prepare_static_gx(
render_device: Res,
render_queue: Res,
asset_server: Res,
+ mesh_pipeline: Res,
images: Res>,
views: Query,
) {
@@ -362,12 +379,20 @@ fn prepare_static_gx(
};
let key = (msaa.samples(), format);
if pipes.specialized_for != Some(key) {
+ pipes.view_layout = mesh_pipeline
+ .get_view_layout(MeshPipelineViewLayoutKey::from(*msaa))
+ .main_layout
+ .clone();
let shader: Handle =
asset_server.load("embedded://benilla_world/shaders/static_gx.wgsl");
pipes.pipelines.clear();
for cutout in [false, true] {
for two_sided in [false, true] {
let mut defs = vec![];
+ // The world camera uses Bevy's default Gaussian shadow filtering. The retained
+ // pipeline is custom, so it must opt into the same shader branch explicitly;
+ // otherwise `shadows::fetch_directional_shadow` falls back to a constant 1.0.
+ defs.push(ShaderDefVal::from("SHADOW_FILTER_METHOD_GAUSSIAN"));
if cutout {
defs.push(ShaderDefVal::from("GX_CUTOUT"));
}
@@ -375,7 +400,11 @@ fn prepare_static_gx(
label: Some(
format!("static_gx c{} t{}", u8::from(cutout), u8::from(two_sided)).into(),
),
- layout: vec![pipes.view_layout.clone(), pipes.cell_layout.clone()],
+ layout: vec![
+ pipes.view_layout.clone(),
+ pipes.cell_layout.clone(),
+ pipes.light_layout.clone(),
+ ],
vertex: VertexState {
shader: shader.clone(),
shader_defs: defs.clone(),
@@ -705,28 +734,28 @@ fn kill_bit(killed: &[u64], i: usize) -> u32 {
)
}
-/// The per-frame view bind group (group 0): bevy's view uniform + the shared light buffer —
-/// the SAME `wow_shared_light` storage every material binds (1429: identical lighting by
-/// construction).
+/// The retained pass's extra bind group (group 2): the shared light buffer — the SAME
+/// `wow_shared_light` storage every material binds (1429: identical lighting by construction) —
+/// Group 0 is Bevy's standard mesh-view bind group, which supplies the view matrices,
+/// directional-light records, and shadow textures.
#[derive(Resource)]
-struct GxViewBind(BindGroup);
+struct GxLightBind(BindGroup);
fn prepare_view_bind(
mut commands: Commands,
pipes: Res,
pipeline_cache: Res,
render_device: Res,
- view_uniforms: Res,
light: Option>,
) {
- let (Some(view_binding), Some(light)) = (view_uniforms.uniforms.binding(), light) else {
+ let Some(light) = light else {
return;
};
- let layout = pipeline_cache.get_bind_group_layout(&pipes.view_layout);
- commands.insert_resource(GxViewBind(render_device.create_bind_group(
- "static_gx_view",
+ let layout = pipeline_cache.get_bind_group_layout(&pipes.light_layout);
+ commands.insert_resource(GxLightBind(render_device.create_bind_group(
+ "static_gx_light",
&layout,
- &BindGroupEntries::sequential((view_binding, light.0.as_entire_binding())),
+ &BindGroupEntries::sequential((light.0.as_entire_binding(),)),
)));
}
@@ -741,6 +770,13 @@ impl ViewNode for StaticGxNode {
&'static ViewTarget,
&'static ViewDepthTexture,
&'static ViewUniformOffset,
+ &'static ViewLightsUniformOffset,
+ &'static ViewFogUniformOffset,
+ &'static ViewLightProbesUniformOffset,
+ &'static ViewScreenSpaceReflectionsUniformOffset,
+ &'static ViewEnvironmentMapUniformOffset,
+ Option<&'static OrderIndependentTransparencySettingsOffset>,
+ &'static MeshViewBindGroup,
// The world camera only — a booth bake must never receive world cells (see the marker).
&'static StaticGxView,
);
@@ -749,7 +785,19 @@ impl ViewNode for StaticGxNode {
&self,
_graph: &mut RenderGraphContext,
render_context: &mut RenderContext<'w>,
- (target, depth, view_offset, _marker): QueryItem<'w, '_, Self::ViewQuery>,
+ (
+ target,
+ depth,
+ view_offset,
+ view_lights,
+ view_fog,
+ view_light_probes,
+ view_ssr,
+ view_environment_map,
+ maybe_oit,
+ view_bind,
+ _marker,
+ ): QueryItem<'w, '_, Self::ViewQuery>,
world: &'w World,
) -> Result<(), NodeRunError> {
let _t = super::gx_perf_guard(4);
@@ -760,7 +808,7 @@ impl ViewNode for StaticGxNode {
let cache = world.resource::();
let pipes = world.resource::();
let pipeline_cache = world.resource::();
- let Some(view_bind) = world.get_resource::() else {
+ let Some(light_bind) = world.get_resource::() else {
return Ok(());
};
let meshes = world.resource::>();
@@ -818,7 +866,18 @@ impl ViewNode for StaticGxNode {
timestamp_writes: None,
occlusion_query_set: None,
});
- pass.set_bind_group(0, &view_bind.0, &[view_offset.offset]);
+ let mut view_offsets = vec![
+ view_offset.offset,
+ view_lights.offset,
+ view_fog.offset,
+ **view_light_probes,
+ **view_ssr,
+ **view_environment_map,
+ ];
+ if let Some(oit) = maybe_oit {
+ view_offsets.push(oit.offset);
+ }
+ pass.set_bind_group(0, &view_bind.main, &view_offsets);
for (gpu, draw, sel) in &resolved {
let Some(mesh) = meshes.get(draw.mesh.id()) else {
continue;
@@ -847,6 +906,7 @@ impl ViewNode for StaticGxNode {
}
pass.set_render_pipeline(ready[&(run.cutout, run.two_sided)]);
pass.set_bind_group(1, &gpu.bind_groups[run.slot].1, &[]);
+ pass.set_bind_group(2, &light_bind.0, &[]);
pass.draw_indexed(
(islice.range.start + run.index_range.start)
..(islice.range.start + run.index_range.end),
From 3c33fd711769ca0b5b52e69e1bdbf16fdb26e21d Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Thu, 3 Sep 2026 15:18:04 +0100
Subject: [PATCH 04/17] 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,
+ // MONKEY (world shadows): the `worldShadows` lane flag, packed into `sh_c16.w` for the MCSH gate.
+ world_shadow: Res,
mut last_dump: Local,
mut last_rows_dump: Local,
) {
@@ -311,6 +323,10 @@ fn build_light_data(
// Rows 0-2, the SH block 6-12.xyz, and the sun DC (17.yzw) — the shared model-light core
// (also the portrait booth's packer). Row 20 (point_count) is the point-table pack's below.
pack_model_core_rows(rows, l.ambient, l.diffuse, l.sun_dir);
+ // MONKEY (world shadows): pack the world-shadow lane flag into the free `sh_c16.w` lane. The
+ // MCSH terrain-shadow suppression in `terrain.wgsl` keys on THIS — not on the mere presence of
+ // a shadow sun — so character-only shadows (sun present, world lane off) keep the baked MCSH.
+ rows[12][3] = if world_shadow.0 { 1.0 } else { 0.0 };
// The dynamic point-light table (decision 0278): every spawned point light within
// [`POINT_PACK_RADIUS`] of the camera, nearest-first when over capacity — the VERTEX stages of
// `terrain.wgsl`/`wow_model.wgsl` walk it for the Gouraud point term (bevy's clusterable buffer
diff --git a/crates/benilla-world/src/lighting/mod.rs b/crates/benilla-world/src/lighting/mod.rs
index ff7da82e..02a0afe3 100644
--- a/crates/benilla-world/src/lighting/mod.rs
+++ b/crates/benilla-world/src/lighting/mod.rs
@@ -17,7 +17,7 @@ mod prop_probes; // the per-instance interior-prop SH probe table (slot ↔ Mesh
mod resolve; // the per-frame time-of-day sample into WowLighting + the WMO interior-fog crossfade
mod sh; // the model SH light-probe coefficient math
pub use blob::LightBlob;
-pub use global_light::{new_shared_light_buffer, LightRooms, SharedLightBuffer};
+pub use global_light::{new_shared_light_buffer, LightRooms, SharedLightBuffer, WorldShadowActive};
pub use prop_probes::{PropProbeSlot, PropProbes, MAX_PROP_PROBES};
// The std430 layout itself — row indices, byte sizes, region offsets and the folds that fill them
// — stays in the crate. Off-world producers state values through `LightBlob` and never a row index
diff --git a/crates/benilla-world/src/static_gx/mod.rs b/crates/benilla-world/src/static_gx/mod.rs
index 84f4af97..6138bc13 100644
--- a/crates/benilla-world/src/static_gx/mod.rs
+++ b/crates/benilla-world/src/static_gx/mod.rs
@@ -132,6 +132,8 @@ mod cull;
mod pick;
mod pool;
mod render;
+mod shadow; // MONKEY (world shadows): CPU triangle collection for the static-world shadow caster
+pub use shadow::CutoutBucket; // MONKEY (world shadows): per-leaf-texture alpha-cutout caster group
/// The doodad spatial cell — ¼ ADT tile, the same 133⅓-yd locality key the merge lanes use
/// (`terrain_stream::merge::CELL`; 1413 round 2 proved the locality load-bearing).
diff --git a/crates/benilla-world/src/static_gx/shadow.rs b/crates/benilla-world/src/static_gx/shadow.rs
new file mode 100644
index 00000000..ed86e130
--- /dev/null
+++ b/crates/benilla-world/src/static_gx/shadow.rs
@@ -0,0 +1,164 @@
+//! MONKEY (world shadows): CPU collection of the RETAINED static world's geometry as shadow-caster
+//! triangles — the same source-submesh reachback `pick.rs` does for ray hits, appended into a
+//! world-space triangle list. The world-shadow lane (`benilla-app::world_shadow`) appends
+//! these into a cached, layer-31 caster mesh so trees and buildings cast into the SAME Bevy
+//! directional shadow map as characters, instead of only their baked shading.
+//!
+//! The retained pass recentres vertices per cell and bakes them into a `RENDER_WORLD`-only mesh
+//! whose CPU data is gone after extract — so we go to the SOURCE the divert kept
+//! ([`super::GxItem::geometry`] + [`super::GxItem::transform`]), exactly as `pick.rs` does, and
+//! reconstruct world space directly: `transform.transform_point(wow_to_bevy(pos))`.
+
+use benilla_assets::coords::wow_to_bevy;
+use bevy::asset::AssetId;
+use bevy::image::Image;
+use bevy::platform::collections::HashMap;
+use bevy::prelude::*;
+
+use super::{GxCell, StaticGx};
+
+/// One alpha-tested foliage caster group: every resident cutout batch that shares a single leaf
+/// texture, merged into one world-space triangle list carrying UVs. The character-shadow system
+/// drives one layer-31 caster entity + [`super::super`]-side `CutoutShadowCasterMaterial` per
+/// bucket, so the shadow pass can sample this texture and discard transparent texels — casting a
+/// leaf-SHAPED silhouette instead of the solid box a positions-only proxy throws.
+pub struct CutoutBucket {
+ /// The grouping key: the leaf sheet's asset id (stable across rebuilds, so the caller keys a
+ /// persistent caster entity off it rather than churning one per rebuild).
+ pub texture_id: AssetId,
+ /// A live strong handle to the same leaf image the forward pass draws — put straight on the
+ /// caster material so its sampler inherits the image's own clamp/repeat address mode.
+ pub texture: Handle,
+ /// World-space (Bevy) caster positions.
+ pub positions: Vec<[f32; 3]>,
+ /// Per-vertex UVs, parallel to [`Self::positions`] — what the prepass fragment samples.
+ pub uvs: Vec<[f32; 2]>,
+ pub indices: Vec,
+}
+
+impl StaticGx {
+ /// Append world-space (Bevy) shadow-caster triangles for resident static geometry whose
+ /// placement anchor is within `reach` of `center`, onto the caller's buffers (the character
+ /// system recycles them across rebuilds). Whole triangles referencing out-of-range indices are
+ /// dropped — a truncated submesh must never rewire a later triangle into garbage (mirrors
+ /// `character_shadow::append_triangles`).
+ ///
+ /// FIRST CUT: a currently-EXILED (distance-faded) static item also draws as an entity, so it is
+ /// collected by both lanes and its geometry lands in the caster twice — harmless (identical
+ /// triangles at identical depth), a follow-up can gate on the fader state.
+ pub fn append_shadow_triangles(
+ &self,
+ center: Vec3,
+ reach: f32,
+ positions: &mut Vec<[f32; 3]>,
+ indices: &mut Vec,
+ ) {
+ let r2 = reach * reach;
+ let mut push = |cell: &GxCell| {
+ for item in &cell.items {
+ // Distance gate on the placement anchor BEFORE any vertex work.
+ if item.transform.translation.distance_squared(center) > r2 {
+ continue;
+ }
+ // MONKEY (world shadows): the SOLID caster skips alpha-cutout foliage — a leaf card
+ // is a big flat quad, and cast solid here (this mesh has no texture) it would be an
+ // ugly rectangular BOX on the ground. Trunks/buildings/fences (opaque) cast their
+ // real silhouette here; cutout foliage is collected leaf-shaped by
+ // [`Self::collect_cutout_shadow_triangles`] instead. (`item.cutout` == AlphaTest.)
+ if item.cutout {
+ continue;
+ }
+ let base = positions.len() as u32;
+ let t = item.transform;
+ positions.extend(
+ item.geometry
+ .positions
+ .iter()
+ .map(|p| t.transform_point(wow_to_bevy(*p)).to_array()),
+ );
+ let added = positions.len() as u32 - base;
+ for tri in item.geometry.indices.chunks_exact(3) {
+ if tri.iter().all(|i| *i < added) {
+ indices.extend(tri.iter().map(|i| base + *i));
+ }
+ }
+ }
+ };
+ for cell in self.cells.values() {
+ push(cell);
+ }
+ for cell in self.wmos.values() {
+ push(cell);
+ }
+ for cell in self.props.values() {
+ push(cell);
+ }
+ }
+
+ /// Collect the resident ALPHA-CUTOUT geometry (tree canopies, bushes, cattails — the leaf
+ /// cards the solid caster skips) as one [`CutoutBucket`] per distinct leaf texture, each a
+ /// world-space triangle list with UVs. The character-shadow system drives one alpha-tested
+ /// layer-31 caster per bucket, so a canopy discards its transparent texels in the shadow pass
+ /// and casts a leaf-SHAPED silhouette. Grouping by texture is what lets a single draw carry
+ /// one sheet: a zone's canopies draw from only a handful of sheets, so this yields a handful
+ /// of buckets. Range-gates on the placement anchor exactly like [`Self::append_shadow_triangles`].
+ ///
+ /// A cutout batch without a texture handle, or whose UVs don't parallel its positions, is
+ /// skipped (it can't be alpha-tested — better no shadow than a wrong solid box).
+ pub fn collect_cutout_shadow_triangles(&self, center: Vec3, reach: f32) -> Vec {
+ let r2 = reach * reach;
+ let mut buckets: HashMap, CutoutBucket> = HashMap::new();
+ let mut push = |cell: &GxCell| {
+ for item in &cell.items {
+ if !item.cutout {
+ continue;
+ }
+ let (Some(texture_id), Some(texture)) = (item.texture, item.texture_handle.as_ref())
+ else {
+ continue;
+ };
+ if item.transform.translation.distance_squared(center) > r2 {
+ continue;
+ }
+ let geometry = &item.geometry;
+ // UVs must parallel positions to map per-vertex — a cutout card that lost its UVs
+ // (should never happen) can't be sampled, so drop it rather than box it out.
+ if geometry.uvs.len() != geometry.positions.len() {
+ continue;
+ }
+ let bucket = buckets.entry(texture_id).or_insert_with(|| CutoutBucket {
+ texture_id,
+ texture: texture.clone(),
+ positions: Vec::new(),
+ uvs: Vec::new(),
+ indices: Vec::new(),
+ });
+ let base = bucket.positions.len() as u32;
+ let t = item.transform;
+ bucket.positions.extend(
+ geometry
+ .positions
+ .iter()
+ .map(|p| t.transform_point(wow_to_bevy(*p)).to_array()),
+ );
+ bucket.uvs.extend_from_slice(&geometry.uvs);
+ let added = bucket.positions.len() as u32 - base;
+ for tri in geometry.indices.chunks_exact(3) {
+ if tri.iter().all(|i| *i < added) {
+ bucket.indices.extend(tri.iter().map(|i| base + *i));
+ }
+ }
+ }
+ };
+ for cell in self.cells.values() {
+ push(cell);
+ }
+ for cell in self.wmos.values() {
+ push(cell);
+ }
+ for cell in self.props.values() {
+ push(cell);
+ }
+ buckets.into_values().collect()
+ }
+}
From 97e2d672ee212b0c489a48e52c6d91a3fd71b453 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Sat, 5 Sep 2026 16:44:30 +0100
Subject: [PATCH 05/17] MONKEY: shadow_core framework + moving sun + edge/night
fade
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Refactor the shadow feature into a shared framework + two equal lanes, and
add two visual improvements. Confirmed in-game.
- shadow_core.rs (NEW): the shared rig both lanes plug into — one directional
sun on the private layer, the camera-layer plumbing, the shadow map, the
invisible proxy material, the sun aiming, and the shared caster-collection
helpers. Lanes coordinate through a ShadowDemand/ShadowFrame handshake (rig
runs first each frame, publishes the frame; lanes declare demand + build their
casters after). character_shadow and world_shadow are now EQUAL, independent
lane plugins that depend only on shadow_core, never on each other — add,
remove or reorder either freely.
- character_shadow.rs: slimmed to just the CREATURE caster lane.
- world_shadow.rs: the WORLD lane — static_gx solid + alpha-tested foliage +
a per-frame entity-resident environment caster (the two entity casters were
one shared mesh before; splitting them is what makes the lanes independent).
- Moving sun: the shadow basis now tracks the VISIBLE celestial sun (which
genuinely rises/sets) instead of the near-fixed lighting sun, so shadows sweep
through the day. Elevation clamped to ~18..65 deg so a set/zenith sun never
inverts or degenerates the basis; azimuth preserved.
- Edge fade: the realtime shadow lightens toward the cascade's max distance so
it fades in rather than popping at the resolve boundary.
- Night fade: shadow strength ramps by the real sun height (packed into the free
fog_params.z light lane), so shadows soften and vanish at night; the baked MCSH
terrain shadows fade back IN at night for the world lane, keyed off the same.
Co-Authored-By: Claude Opus 4.8
---
.gitignore | 1 +
crates/benilla-app/src/character_shadow.rs | 799 ++----------------
crates/benilla-app/src/lib.rs | 8 +-
crates/benilla-app/src/shadow_core.rs | 648 ++++++++++++++
crates/benilla-app/src/world_shadow.rs | 306 +++++--
.../benilla-assets/src/shaders/terrain.wgsl | 21 +-
.../benilla-assets/src/shaders/wow_model.wgsl | 13 +
.../src/lighting/global_light.rs | 13 +
crates/benilla-world/src/lighting/mod.rs | 7 +
9 files changed, 998 insertions(+), 818 deletions(-)
create mode 100644 crates/benilla-app/src/shadow_core.rs
diff --git a/.gitignore b/.gitignore
index 7664883e..c095e452 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,4 @@ trace-*.json
# MONKEY: per-run client state written by benilla (saved variables, camera) — not source.
/benilla-config/
+/benilla.bat
diff --git a/crates/benilla-app/src/character_shadow.rs b/crates/benilla-app/src/character_shadow.rs
index 1eb0f3b9..56a8209a 100644
--- a/crates/benilla-app/src/character_shadow.rs
+++ b/crates/benilla-app/src/character_shadow.rs
@@ -1,285 +1,52 @@
-//! Real shadow-map support for benilla, split into two INDEPENDENT lanes over ONE shared rig.
+//! The CHARACTER shadow lane — a thin plug-in on top of [`super::shadow_core`].
//!
-//! 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.
+//! It owns nothing but its own per-frame caster (players, NPCs, creatures, mounts). When
+//! `characterShadows` is on it declares demand to the shared rig ([`ShadowDemand`]); while the rig
+//! is live it collects the CREATURE entity parts into its caster using the rig's shared material and
+//! the per-frame facts the rig publishes ([`ShadowFrame`]). It does not know [`super::world_shadow`]
+//! exists — remove either lane and the other is untouched.
-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::pbr::MeshMaterial3d;
use bevy::prelude::*;
-use bevy::reflect::TypePath;
-use bevy::render::render_resource::{
- AsBindGroup, RenderPipelineDescriptor, SpecializedMeshPipelineError,
-};
-use bevy::shader::ShaderRef;
-use benilla_assets::coords::wow_to_bevy;
use benilla_assets::materials::WowModelMaterial;
-use benilla_formats::ModelBlend;
use benilla_world::billboard::BillboardCard;
use benilla_world::interact::PickMesh;
-use benilla_world::lighting::{WorldShadowActive, WowLighting};
-use benilla_world::model_render::{ModelKind, ModelPart, ShadowOccluder};
+use benilla_world::model_render::{ModelPart, ShadowOccluder};
use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
-use benilla_world::view::WorldCamera;
-use crate::char_select::ClientState;
+use crate::shadow_core::{
+ collect_entity_geometry, empty_shadow_mesh, restore_mesh_buffers, shadow_trace,
+ spawn_solid_caster, take_mesh_buffers, ShadowDemand, ShadowFrame, ShadowSet,
+};
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.
-pub(crate) const PLAYER_SHADOW_LAYER: usize = 31;
+pub(crate) struct CharacterShadowPlugin;
-/// 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 — in light space a caster's footprint sits exactly where
-/// its shadow falls, so an out-of-frustum tree still darkens ground the player is looking at.
-/// See [`static_collection_reach`] for the collection law.
-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.
-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
-/// collection hunts for shadow SOURCES.
-const MAX_CASTER_HEIGHT: f32 = 35.0;
-
-/// Ceiling on the shadow-reach extension: the lighting sun's elevation only spans ~20°–37°
-/// over the whole day, but a degenerate near-horizontal direction must not explode the
-/// collection radius (and with it the rebuild cost) unbounded.
-const SHADOW_REACH_CAP: f32 = 120.0;
-
-/// How far past a caster's own position its shadow can land: `height × horizontal/vertical`
-/// of the lighting sun's travel direction. At the night elevation (~20°) a 35-unit tree throws
-/// a ~96-unit shadow — collecting only to `CASTER_RANGE + STATIC_REBUILD_STEP` made exactly
-/// those trees pop in as the camera drifted a rebuild step, their long shadows sweeping across
-/// ground the player was already looking at.
-fn shadow_reach_extension(sun_travel: Vec3) -> f32 {
- // `sun_travel` is the direction the light TRAVELS (downward): -y is the vertical drop.
- let down = -sun_travel.y;
- if down <= 1e-3 {
- return SHADOW_REACH_CAP;
- }
- let horizontal = Vec3::new(sun_travel.x, 0.0, sun_travel.z).length();
- (MAX_CASTER_HEIGHT * horizontal / down).min(SHADOW_REACH_CAP)
-}
-
-/// The one collection law for a lane that can contribute a TALL caster (a doodad/WMO entity
-/// 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)
-}
-
-/// `WOW_SHADOW_TRACE=1` — log the caster population whenever it CHANGES, so "the shadow
-/// vanished when I turned" can be attributed to a lane: a dynamic-mesh count that dips with
-/// 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.
-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"))
-}
-
-/// How far the LIVE sun may drift from the direction the shadow light was last given before
-/// the light snaps to it — about one game minute of lighting-sun travel (the DayNight phi
-/// table sweeps ~0.297 rad over 360 game minutes ≈ 8.25e-4 rad/min).
-///
-/// The shadow basis is quantised because a light that rotates EVERY frame defeats the cascade
-/// fit's texel snap: `calculate_cascade` (bevy_light) snaps the ortho origin to texel
-/// multiples in LIGHT space, which pins shadow edges to world positions under camera
-/// translation — but rotating the basis re-maps every world position, and at WoW map
-/// coordinates (Elwynn sits ~9,300 units from the world origin) even the lighting sun's tiny
-/// ~1.4e-5 rad/s drift slides light-space positions ~0.13 units/s ≈ 1.6 shadow texels/s: a
-/// continuous sub-texel re-rasterisation crawl on every shadow edge (the inn-roof field
-/// report). Held between snaps, the basis is bit-stable and the map only re-rasterises when
-/// the sun has honestly moved — a discrete 1–2 texel tick roughly once a game minute, the
-/// same physical reasoning that lets MCSH ship as a static bake. LIGHTING stays continuous;
-/// only the shadow basis steps.
-const SUN_SNAP_RADIANS: f32 = 8.0e-4;
-
-/// Whether the live sun direction has drifted far enough from the held (last-written) one to
-/// re-aim the shadow light. `None` = never written (first frame after spawn, or re-enable
-/// after teardown): always due.
-fn sun_snap_due(held: Option, live: Vec3) -> bool {
- match held {
- None => true,
- Some(held) => held.angle_between(live) > SUN_SNAP_RADIANS,
- }
-}
-
-#[derive(Component)]
-pub(crate) struct WorldShadowCaster;
-
-#[derive(Component)]
-struct WorldShadowSun;
-
-#[derive(Component)]
-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)]
-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.
- sun_written: 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).
- 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 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. Shared by both lanes' SOLID casters.
-#[derive(Asset, AsBindGroup, TypePath, Debug, Clone, Default)]
-pub(crate) struct ShadowCasterMaterial {}
-
-impl Material for ShadowCasterMaterial {
- fn fragment_shader() -> ShaderRef {
- "embedded://benilla_app/shaders/shadow_caster.wgsl".into()
- }
-
- fn specialize(
- _pipeline: &MaterialPipeline,
- descriptor: &mut RenderPipelineDescriptor,
- _layout: &bevy::mesh::MeshVertexBufferLayoutRef,
- _key: bevy::pbr::MaterialPipelineKey,
- ) -> Result<(), SpecializedMeshPipelineError> {
- // WoW's model batches are frequently authored as two-sided geometry. Keeping both faces
- // in the caster avoids holes in the projected silhouette when a batch has mixed winding.
- descriptor.primitive.cull_mode = None;
- Ok(())
- }
-}
-
-/// 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(())
- }
-}
-
-/// 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 {
+impl Plugin for CharacterShadowPlugin {
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);
+ app.init_resource::()
+ .add_systems(Last, collect_character_shadows.in_set(ShadowSet::Lanes));
}
}
+/// The character lane's retained per-frame caster.
+#[derive(Resource, Default)]
+struct CharacterLane {
+ caster: Option,
+ mesh: Option>,
+ /// [`shadow_trace`]'s change detector: (tris, admitted, rejected).
+ traced: (u32, u32, u32),
+}
+
#[allow(clippy::too_many_arguments)]
-fn update_shadows(
- state: Res>,
+fn collect_character_shadows(
video: Res,
- lighting: Res,
- mut runtime: ResMut,
+ mut demand: ResMut,
+ frame: Res,
+ mut lane: 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,
@@ -293,516 +60,60 @@ fn update_shadows(
>,
rigs: Query<&RigSkin>,
palettes: Res,
- mut cameras: Query<
- (
- Entity,
- Option<&mut RenderLayers>,
- Option<&WorldShadowCameraLayer>,
- ),
- With,
- >,
- 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 in_world = *state.get() == ClientState::InWorld;
- let world_on = video.world_shadows && in_world;
- let char_on = video.character_shadows && in_world;
+ let on = video.character_shadows;
+ // Declare demand so the shared rig stays up while this lane is on (the rig reads it next frame).
+ demand.0 = demand.0 || on;
- // 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() {
+ // Rig down or lane off → drop our caster so it stops casting.
+ if !(frame.active && on) {
+ if let Some(entity) = lane.caster.take() {
commands.entity(entity).despawn();
}
- if let Some(entity) = runtime.sun.take() {
- commands.entity(entity).despawn();
- }
- // Forget the held shadow basis with its light, so a re-enable aims the fresh sun on
- // its first frame rather than waiting out a snap threshold.
- runtime.sun_written = None;
- if let Some(handle) = runtime.dynamic_mesh.take() {
+ if let Some(handle) = lane.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());
- }
- for (entity, layers, marker) in &mut cameras {
- let Some(marker) = marker else { continue };
- if marker.had_layers {
- if let Some(mut layers) = layers {
- *layers = layers.clone().without(PLAYER_SHADOW_LAYER);
- }
- } else {
- commands.entity(entity).remove::();
- }
- commands.entity(entity).remove::();
- }
return;
}
-
- // --- 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 {
- // The light exists to populate a shadow map. WoW's existing shader lighting
- // remains authoritative, so this light must not add a second diffuse term.
- illuminance: 0.0,
- 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.
- shadow_depth_bias: 0.02,
- shadow_normal_bias: 0.8,
- ..default()
- },
- CascadeShadowConfigBuilder {
- // 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,
- ..default()
- }
- .build(),
- Transform::IDENTITY,
- Visibility::Visible,
- RenderLayers::layer(PLAYER_SHADOW_LAYER),
- WorldShadowSun,
- ))
- .id();
- runtime.sun = Some(sun);
- runtime.sun_written = None; // aim it on the first frame
- }
-
- for (entity, layers, marker) in &mut cameras {
- if marker.is_some() {
- if let Some(mut layers) = layers {
- *layers = layers.clone().with(PLAYER_SHADOW_LAYER);
- }
- } else if let Some(mut layers) = layers {
- *layers = layers.clone().with(PLAYER_SHADOW_LAYER);
- commands
- .entity(entity)
- .insert(WorldShadowCameraLayer { had_layers: true });
- } else {
- commands.entity(entity).insert((
- RenderLayers::default().with(PLAYER_SHADOW_LAYER),
- WorldShadowCameraLayer { had_layers: false },
- ));
- }
- }
-
- let light_position = cameras_for_position
- .iter()
- .next()
- .map(GlobalTransform::translation)
- .unwrap_or(Vec3::ZERO);
-
- let sun_direction = if lighting.sun_dir.length_squared() > 1e-6 {
- lighting.sun_dir.normalize()
- } else {
- Vec3::NEG_Z
+ let Some(material) = frame.material.clone() else {
+ return;
};
- // 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);
- // --- 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() {
+ // Animated rigs re-skin every frame and creatures spawn/despawn under lifecycles this lane
+ // can't cheaply observe — so the caster is collected fresh every frame.
+ if lane.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);
+ lane.caster = Some(entity);
+ lane.mesh = Some(mesh);
}
- if let Some(handle) = runtime.dynamic_mesh.clone() {
+ if let Some(handle) = lane.mesh.clone() {
if let Some(mesh) = meshes.get_mut(&handle) {
let (mut positions, mut indices) = take_mesh_buffers(mesh);
let (admitted, rejected) = collect_entity_geometry(
&parts,
&rigs,
&palettes,
- char_on,
- world_on,
- light_position,
- entity_reach,
- tall_reach,
+ true, // want_creatures
+ false, // want_environment (the world lane's job)
+ frame.light_position,
+ frame.entity_reach,
+ frame.tall_reach,
&mut positions,
&mut indices,
);
- let dynamic_tris = (indices.len() / 3) as u32;
+ let tris = (indices.len() / 3) as u32;
restore_mesh_buffers(mesh, positions, indices);
if shadow_trace() {
- let now = (dynamic_tris, admitted, rejected);
- if runtime.traced != now {
+ let now = (tris, admitted, rejected);
+ if lane.traced != now {
info!(
- "shadow-trace: dynamic {} tris | parts admitted {} / occluder-rejected {} (was {:?})",
- dynamic_tris, admitted, rejected, runtime.traced
+ "shadow-trace: character {} tris | creatures admitted {} / rejected {} (was {:?})",
+ tris, admitted, rejected, lane.traced
);
- runtime.traced = now;
+ lane.traced = now;
}
}
}
}
-
- // --- 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 {
- 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);
- runtime.sun_written = Some(sun_direction);
- }
- }
- }
-}
-
-/// 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.
-fn append_triangles(source: &[u32], vertex_count: u32, base: u32, indices: &mut Vec) {
- for triangle in source.chunks_exact(3) {
- if triangle.iter().all(|index| *index < vertex_count) {
- indices.extend(triangle.iter().map(|index| base + *index));
- }
- }
-}
-
-/// 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.
-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(),
- };
- let mut indices = match mesh.remove_indices() {
- Some(Indices::U32(values)) => values,
- _ => Vec::new(),
- };
- positions.clear();
- indices.clear();
- (positions, indices)
-}
-
-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));
-}
-
-pub(crate) fn empty_shadow_mesh() -> Mesh {
- let mut mesh = Mesh::new(
- PrimitiveTopology::TriangleList,
- RenderAssetUsages::default(),
- );
- mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, Vec::<[f32; 3]>::new());
- mesh.insert_indices(Indices::U32(Vec::new()));
- mesh
-}
-
-/// 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,
- &ModelPart,
- Option<&GlobalTransform>,
- Option<&RigPart>,
- &ShadowOccluder,
- Option<&MeshMaterial3d>,
- ),
- Without,
- >,
- rigs: &Query<&RigSkin>,
- palettes: &RigPalettes,
- char_on: bool,
- world_on: bool,
- light_position: Vec3,
- creature_reach: f32,
- doodad_reach: f32,
- positions: &mut Vec<[f32; 3]>,
- indices: &mut Vec,
-) -> (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, char_on, world_on) {
- continue;
- }
- // NOT `InheritedVisibility`: that folds the exterior window gate, the portal PVS and
- // the exterior-scene cull — all camera-orientation verdicts — and a tree behind the
- // camera still blocks the sun. `ShadowOccluder` is the content half of the same law
- // (toggles, far clip, distance fade, material alpha), written by the one visibility
- // authority.
- if !occluder.0 {
- rejected += 1;
- continue;
- }
- // The distance gate, BEFORE any vertex work: the cascade resolves to CASTER_RANGE, so
- // 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,
- };
- // A rig part's placement lives in its palette (world-space rows), not necessarily a
- // propagated `GlobalTransform`; prefer the transform when present, else the rig's
- // slot origin — both are one lookup, never a skin.
- let anchor = global.map(GlobalTransform::translation).or_else(|| {
- rig_part
- .and_then(|rig_part| rigs.get(rig_part.0).ok())
- .and_then(|rig| palettes.slot_origin(rig.slot))
- });
- let Some(anchor) = anchor else { continue };
- if anchor.distance_squared(light_position) > reach * reach {
- continue;
- }
- admitted += 1;
- let base = positions.len() as u32;
- if let Some(rig_part) = rig_part {
- append_skinned(pick, rig_part, rigs, palettes, positions);
- } else if let Some(global) = global {
- positions.extend(
- pick.0
- .positions
- .iter()
- .map(|p| global.transform_point(wow_to_bevy(*p)).to_array()),
- );
- }
- let added = positions.len() as u32 - base;
- if added != 0 {
- append_triangles(&pick.0.indices, added, base, indices);
- }
- }
- (admitted, rejected)
-}
-
-/// 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(
- pick: &PickMesh,
- rig_part: &RigPart,
- rigs: &Query<&RigSkin>,
- palettes: &RigPalettes,
- positions: &mut Vec<[f32; 3]>,
-) {
- let Ok(rig) = rigs.get(rig_part.0) else {
- return;
- };
- let Some(palette) = palettes.world_palette(rig.slot, rig.bones() as usize) else {
- return;
- };
- for (index, position) in pick.0.positions.iter().enumerate() {
- // A vertex without skinning data still gets an entry: skipping it would shorten
- // `positions` against the index buffer and rewire every later triangle in the
- // submesh. Seat it on the root matrix so it at least stays near the model.
- let (Some(joints), Some(weights)) = (pick.0.joints.get(index), pick.0.weights.get(index))
- else {
- let fallback = palette
- .get(0)
- .map(|matrix| matrix.transform_point3(wow_to_bevy(*position)))
- .unwrap_or(Vec3::ZERO);
- positions.push(fallback.to_array());
- continue;
- };
- let mut skinned = Vec3::ZERO;
- for lane in 0..4 {
- if weights[lane] > 0.0 {
- if let Some(matrix) = palette.get(joints[lane] as usize) {
- skinned += matrix.transform_point3(wow_to_bevy(*position)) * weights[lane];
- }
- }
- }
- positions.push(skinned.to_array());
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::{
- append_triangles, casts_realtime_shadow, shadow_reach_extension, static_collection_reach,
- sun_snap_due, CASTER_RANGE, MAX_CASTER_HEIGHT, SHADOW_REACH_CAP, STATIC_REBUILD_STEP,
- SUN_SNAP_RADIANS,
- };
- use benilla_formats::ModelBlend;
- use benilla_world::model_render::ModelKind;
- use bevy::prelude::Vec3;
-
- #[test]
- 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, 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]
- fn the_shadow_sun_snaps_on_first_write_holds_under_drift_and_rearms() {
- let held = Vec3::new(0.3, -0.8, 0.52).normalize();
- // Never written (spawn, or re-enable after teardown): always due.
- assert!(sun_snap_due(None, held), "first write always fires");
- // Sub-threshold drift holds the basis bit-stable — this is the whole point.
- let axis = Vec3::Y;
- let small = bevy::prelude::Quat::from_axis_angle(axis, SUN_SNAP_RADIANS * 0.5) * held;
- assert!(
- !sun_snap_due(Some(held), small),
- "sub-threshold drift holds"
- );
- // Past the threshold the light snaps…
- let large = bevy::prelude::Quat::from_axis_angle(axis, SUN_SNAP_RADIANS * 2.0) * held;
- assert!(sun_snap_due(Some(held), large), "over-threshold snaps");
- // …and the new held value re-arms the window around the NEW direction.
- assert!(
- !sun_snap_due(Some(large), large),
- "identical direction never rewrites"
- );
- let drift_from_new =
- bevy::prelude::Quat::from_axis_angle(axis, SUN_SNAP_RADIANS * 0.5) * large;
- assert!(
- !sun_snap_due(Some(large), drift_from_new),
- "window re-arms around the snapped direction"
- );
- }
-
- #[test]
- fn shadow_reach_follows_the_sun_and_is_capped() {
- // 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.
- 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.
- 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.
- assert!(static_collection_reach(Vec3::NEG_Y) >= CASTER_RANGE + STATIC_REBUILD_STEP);
- }
-
- #[test]
- fn an_out_of_range_index_drops_its_whole_triangle_and_no_others() {
- let mut out = Vec::new();
- // Middle triangle references vertex 9 of 3 — the whole triple must go, and the last
- // triangle must keep its own vertices (a single dropped index would shift it).
- append_triangles(&[0, 1, 2, 1, 2, 9, 2, 0, 1], 3, 10, &mut out);
- assert_eq!(out, vec![10, 11, 12, 12, 10, 11]);
- }
-
- #[test]
- fn a_trailing_partial_triple_is_ignored() {
- let mut out = Vec::new();
- append_triangles(&[0, 1, 2, 0, 1], 3, 0, &mut out);
- assert_eq!(out, vec![0, 1, 2]);
- }
}
diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs
index 494a9bf6..99cb8217 100644
--- a/crates/benilla-app/src/lib.rs
+++ b/crates/benilla-app/src/lib.rs
@@ -107,6 +107,7 @@ mod run_mode;
mod screen_fade;
mod screenshot;
mod shaders;
+mod shadow_core;
mod name_persist;
/// Where "the client is going down" may be observed, and why that is `Last` and not `Update`
@@ -617,7 +618,12 @@ 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(character_shadow::ShadowPlugin)
+ // MONKEY (shadows): the shared rig (loads first) + the two independent lane plugins.
+ .add_plugins((
+ shadow_core::ShadowCorePlugin,
+ character_shadow::CharacterShadowPlugin,
+ world_shadow::WorldShadowPlugin,
+ ))
// 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/shadow_core.rs b/crates/benilla-app/src/shadow_core.rs
new file mode 100644
index 00000000..e2fb1bb1
--- /dev/null
+++ b/crates/benilla-app/src/shadow_core.rs
@@ -0,0 +1,648 @@
+//! The shared **shadow rig** — the framework both shadow lanes plug into.
+//!
+//! A realtime shadow needs three things: something to CAST (a lane's caster geometry), a LIGHT +
+//! shadow map to cast INTO, and RECEIVERS that read the map and darken. This module owns the middle
+//! part — one directional "sun" on a private render layer, that layer's membership on the world
+//! camera, the shadow-map resource, the invisible proxy caster material, and the sun aiming — plus
+//! the geometry-collection helpers the lanes share. The receivers live in benilla's own shaders.
+//!
+//! The two lanes ([`super::character_shadow`], [`super::world_shadow`]) are EQUAL and independent:
+//! each is its own plugin that depends only on this core, never on the other. They coordinate with
+//! the rig through two resources, with a deliberate one-frame handshake:
+//! - [`ShadowDemand`] — each active lane ORs `true` in (in the [`ShadowSet::Lanes`] set); the rig
+//! reads it next frame to decide whether the sun should exist. A lane that is removed simply stops
+//! contributing demand — the core needs no edit.
+//! - [`ShadowFrame`] — the rig publishes the shared per-frame facts (is it active, the caster
+//! material, the camera/light position, the sun direction, the two collection reaches) for the
+//! lanes to read when they build their casters.
+//!
+//! Why ONE shared light and not one per lane: benilla's custom receiver shaders sample a SINGLE
+//! directional light (they assign, not accumulate, over the light loop). Two lights would double the
+//! shadow-pass cost and the last would win the receiver. So the lanes share this rig and each merely
+//! contributes its own caster geometry into the one map.
+//!
+//! The rig deliberately does NOT put a directional light on the normal world layer: that would make
+//! every existing WoW material enter Bevy's shadow-prepass path, the source of the pipeline
+//! corruption seen during the first experiment. Only the private-layer proxy casts.
+
+use bevy::asset::RenderAssetUsages;
+use bevy::camera::visibility::{NoFrustumCulling, RenderLayers};
+use bevy::light::{
+ CascadeShadowConfigBuilder, DirectionalLight, DirectionalLightShadowMap, NotShadowReceiver,
+};
+use bevy::mesh::{Indices, PrimitiveTopology};
+use bevy::pbr::{Material, MaterialPipeline, MaterialPlugin, MeshMaterial3d};
+use bevy::prelude::*;
+use bevy::reflect::TypePath;
+use bevy::render::render_resource::{
+ AsBindGroup, RenderPipelineDescriptor, SpecializedMeshPipelineError,
+};
+use bevy::shader::ShaderRef;
+
+use benilla_assets::coords::wow_to_bevy;
+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::model_render::{ModelKind, ModelPart, ShadowOccluder};
+use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
+use benilla_world::view::WorldCamera;
+
+use crate::char_select::ClientState;
+
+/// 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;
+
+/// 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.
+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 collection hunts.
+const MAX_CASTER_HEIGHT: f32 = 35.0;
+
+/// Ceiling on the shadow-reach extension: a degenerate near-horizontal sun must not explode the
+/// collection radius (and with it the rebuild cost) unbounded.
+const SHADOW_REACH_CAP: f32 = 120.0;
+
+/// How far the LIVE sun may drift from the last-written direction before the shadow light snaps to
+/// it — about one game minute of travel. The basis is quantised because a light that rotates EVERY
+/// frame defeats the cascade fit's texel snap and crawls every shadow edge at WoW map coordinates
+/// (~9,300 units out). Held between snaps the basis is bit-stable; only the shadow basis steps.
+const SUN_SNAP_RADIANS: f32 = 8.0e-4;
+
+/// The daytime elevation band (radians above the horizon) the shadow sun is clamped to. The visible
+/// celestial sun sweeps roughly −10°..+85° and dips below the horizon at night; unclamped that would
+/// INVERT the shadow (a set sun's travel points upward) or DEGENERATE the `looking_to` basis at high
+/// noon (travel nearly ∥ up). Pinning to ~18°..65° keeps shadows visible, right-way-up and bounded
+/// while the AZIMUTH still tracks the real sun — so shadows sweep through the day ("moving sun").
+const MIN_SHADOW_SUN_ELEVATION: f32 = 0.314; // ~18°
+const MAX_SHADOW_SUN_ELEVATION: f32 = 1.134; // ~65°
+
+fn shadow_reach_extension(sun_travel: Vec3) -> f32 {
+ // `sun_travel` is the direction the light TRAVELS (downward): -y is the vertical drop.
+ let down = -sun_travel.y;
+ if down <= 1e-3 {
+ return SHADOW_REACH_CAP;
+ }
+ let horizontal = Vec3::new(sun_travel.x, 0.0, sun_travel.z).length();
+ (MAX_CASTER_HEIGHT * horizontal / down).min(SHADOW_REACH_CAP)
+}
+
+/// 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)
+}
+
+/// `WOW_SHADOW_TRACE=1` — log the caster population when it CHANGES, to attribute a vanished shadow
+/// to a lane rather than the map/receiver.
+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"))
+}
+
+fn sun_snap_due(held: Option, live: Vec3) -> bool {
+ match held {
+ None => true,
+ Some(held) => held.angle_between(live) > SUN_SNAP_RADIANS,
+ }
+}
+
+/// The shadow sun's TRAVEL direction (light propagation — points AWAY from the sun) from the visible
+/// celestial TO-sun direction, elevation clamped to [`MIN_SHADOW_SUN_ELEVATION`,
+/// `MAX_SHADOW_SUN_ELEVATION`]. Azimuth is preserved so shadows sweep with the real sun; only the
+/// height is tamed so a set/zenith sun can't invert or degenerate the basis.
+fn shadow_sun_travel(to_sun: Vec3) -> Vec3 {
+ let s = to_sun.normalize_or_zero();
+ if s == Vec3::ZERO {
+ return Vec3::NEG_Z;
+ }
+ let horizontal = Vec3::new(s.x, 0.0, s.z);
+ let bearing = if horizontal.length() > 1e-4 {
+ horizontal.normalize()
+ } else {
+ Vec3::NEG_Z
+ };
+ let elevation = s
+ .y
+ .clamp(-1.0, 1.0)
+ .asin()
+ .clamp(MIN_SHADOW_SUN_ELEVATION, MAX_SHADOW_SUN_ELEVATION);
+ let to_sun_clamped = bearing * elevation.cos() + Vec3::Y * elevation.sin();
+ -to_sun_clamped
+}
+
+/// Marks a layer-31 caster mesh (either lane's). Kept for teardown/inspection symmetry.
+#[derive(Component)]
+pub(crate) struct ShadowCaster;
+
+#[derive(Component)]
+struct ShadowSun;
+
+#[derive(Component)]
+struct ShadowCameraLayer {
+ had_layers: bool,
+}
+
+/// The two ordered phases of the shadow feature within `Last`: the rig runs FIRST (spawns/aims the
+/// sun, publishes [`ShadowFrame`]), then every lane runs in [`ShadowSet::Lanes`] — reading the frame
+/// to build its casters and ORing its demand back in for the rig to read next frame.
+#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
+pub(crate) enum ShadowSet {
+ Rig,
+ Lanes,
+}
+
+/// Each active lane ORs `true` here (in [`ShadowSet::Lanes`]); the rig reads it the next frame to
+/// decide whether the sun should exist, then resets it. A removed lane just stops contributing —
+/// the core needs no edit to notice a lane is gone.
+#[derive(Resource, Default)]
+pub(crate) struct ShadowDemand(pub bool);
+
+/// The rig's per-frame publication for the lanes: whether the rig is live, the shared proxy caster
+/// material, and the shared collection inputs (camera/light position, sun direction, and the two
+/// reaches). A lane builds nothing until `active` and only with the `material` here.
+#[derive(Resource, Default)]
+pub(crate) struct ShadowFrame {
+ pub active: bool,
+ pub light_position: Vec3,
+ pub sun_direction: Vec3,
+ /// Reach for short casters (creatures/gameobjects — at most a body tall).
+ pub entity_reach: f32,
+ /// Reach for tall casters (doodads/WMO/static world) — carries the sun-dependent shadow reach.
+ pub tall_reach: f32,
+ pub material: Option>,
+}
+
+/// The rig's own retained state — private to the core.
+#[derive(Resource, Default)]
+struct ShadowRigState {
+ sun: Option,
+ /// The direction the sun was LAST GIVEN — the held shadow basis (see [`SUN_SNAP_RADIANS`]).
+ sun_written: Option,
+ /// The invisible proxy material shared by every lane's SOLID caster.
+ material: Option>,
+}
+
+pub(crate) struct ShadowCorePlugin;
+
+/// Opaque to Bevy's shadow pass, discarded by the forward pass — the private-layer proxy that lets a
+/// caster participate in the real shadow map without drawing a second copy of the world. Shared by
+/// every lane's SOLID caster.
+#[derive(Asset, AsBindGroup, TypePath, Debug, Clone, Default)]
+pub(crate) struct ShadowCasterMaterial {}
+
+impl Material for ShadowCasterMaterial {
+ fn fragment_shader() -> ShaderRef {
+ "embedded://benilla_app/shaders/shadow_caster.wgsl".into()
+ }
+
+ fn specialize(
+ _pipeline: &MaterialPipeline,
+ descriptor: &mut RenderPipelineDescriptor,
+ _layout: &bevy::mesh::MeshVertexBufferLayoutRef,
+ _key: bevy::pbr::MaterialPipelineKey,
+ ) -> Result<(), SpecializedMeshPipelineError> {
+ // WoW batches are frequently two-sided; keep both faces so a mixed-winding batch's
+ // projected silhouette has no holes.
+ descriptor.primitive.cull_mode = None;
+ Ok(())
+ }
+}
+
+impl Plugin for ShadowCorePlugin {
+ fn build(&self, app: &mut App) {
+ app.insert_resource(DirectionalLightShadowMap { size: 4096 }) // MONKEY: crisper shadow edges
+ .add_plugins(MaterialPlugin::::default())
+ .init_resource::()
+ .init_resource::()
+ .init_resource::()
+ // The rig runs before the lanes every frame; the lanes read the frame it publishes.
+ .configure_sets(Last, (ShadowSet::Rig, ShadowSet::Lanes).chain())
+ .add_systems(Last, manage_rig.in_set(ShadowSet::Rig));
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+fn manage_rig(
+ state: Res>,
+ lighting: Res,
+ mut demand: ResMut,
+ mut frame: ResMut,
+ mut rig: ResMut,
+ mut commands: Commands,
+ mut materials: ResMut>,
+ mut cameras: Query<
+ (Entity, Option<&mut RenderLayers>, Option<&ShadowCameraLayer>),
+ With,
+ >,
+ mut suns: Query<&mut Transform, With>,
+ cameras_for_position: Query<&GlobalTransform, With>,
+) {
+ let in_world = *state.get() == ClientState::InWorld;
+ // 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;
+ demand.0 = false;
+
+ if !wanted {
+ if let Some(entity) = rig.sun.take() {
+ commands.entity(entity).despawn();
+ }
+ // Forget the held basis with the light, so a re-enable aims the fresh sun immediately.
+ rig.sun_written = None;
+ if let Some(handle) = rig.material.take() {
+ materials.remove(handle.id());
+ }
+ for (entity, layers, marker) in &mut cameras {
+ let Some(marker) = marker else { continue };
+ if marker.had_layers {
+ if let Some(mut layers) = layers {
+ *layers = layers.clone().without(PLAYER_SHADOW_LAYER);
+ }
+ } else {
+ commands.entity(entity).remove::();
+ }
+ commands.entity(entity).remove::();
+ }
+ frame.active = false;
+ frame.material = None;
+ return;
+ }
+
+ if rig.material.is_none() {
+ rig.material = Some(materials.add(ShadowCasterMaterial {}));
+ }
+ if rig.sun.is_none() {
+ let sun = commands
+ .spawn((
+ DirectionalLight {
+ // The light exists to populate a shadow map; WoW's shader lighting stays
+ // authoritative, so it must not add a second diffuse term.
+ illuminance: 0.0,
+ shadows_enabled: true,
+ shadow_depth_bias: 0.02,
+ shadow_normal_bias: 0.8,
+ ..default()
+ },
+ CascadeShadowConfigBuilder {
+ num_cascades: 1,
+ minimum_distance: 0.1,
+ maximum_distance: CASTER_RANGE,
+ ..default()
+ }
+ .build(),
+ Transform::IDENTITY,
+ Visibility::Visible,
+ RenderLayers::layer(PLAYER_SHADOW_LAYER),
+ ShadowSun,
+ ))
+ .id();
+ rig.sun = Some(sun);
+ rig.sun_written = None;
+ }
+
+ for (entity, layers, marker) in &mut cameras {
+ if marker.is_some() {
+ if let Some(mut layers) = layers {
+ *layers = layers.clone().with(PLAYER_SHADOW_LAYER);
+ }
+ } else if let Some(mut layers) = layers {
+ *layers = layers.clone().with(PLAYER_SHADOW_LAYER);
+ commands
+ .entity(entity)
+ .insert(ShadowCameraLayer { had_layers: true });
+ } else {
+ commands.entity(entity).insert((
+ RenderLayers::default().with(PLAYER_SHADOW_LAYER),
+ ShadowCameraLayer { had_layers: false },
+ ));
+ }
+ }
+
+ let light_position = cameras_for_position
+ .iter()
+ .next()
+ .map(GlobalTransform::translation)
+ .unwrap_or(Vec3::ZERO);
+
+ // 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);
+
+ if let Some(sun) = rig.sun {
+ // Only the rotation matters; QUANTISED to hold the cascade texel snap (see SUN_SNAP_RADIANS).
+ if sun_snap_due(rig.sun_written, sun_direction) {
+ if let Ok(mut current) = suns.get_mut(sun) {
+ *current = Transform::IDENTITY.looking_to(sun_direction, Vec3::Y);
+ rig.sun_written = Some(sun_direction);
+ }
+ }
+ }
+
+ frame.active = true;
+ frame.light_position = light_position;
+ frame.sun_direction = sun_direction;
+ frame.entity_reach = entity_reach;
+ frame.tall_reach = tall_reach;
+ frame.material = rig.material.clone();
+}
+
+// ---------------------------------------------------------------------------------------------
+// Shared caster-building helpers, used by both lanes.
+// ---------------------------------------------------------------------------------------------
+
+/// Spawn a SOLID caster entity on the private shadow layer with the shared proxy material.
+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,
+ ShadowCaster,
+ ))
+ .id()
+}
+
+/// Append a triangle list, dropping WHOLE triangles whose indices exceed the vertex range (filtering
+/// single indices out of a `TriangleList` rewires the rest of the submesh into garbage).
+pub(crate) fn append_triangles(source: &[u32], vertex_count: u32, base: u32, indices: &mut Vec) {
+ for triangle in source.chunks_exact(3) {
+ if triangle.iter().all(|index| *index < vertex_count) {
+ indices.extend(triangle.iter().map(|index| base + *index));
+ }
+ }
+}
+
+/// Take a caster mesh's own buffers so a rebuild reuses their allocations. The mesh is left
+/// attribute-less until [`restore_mesh_buffers`] puts them back.
+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(),
+ };
+ let mut indices = match mesh.remove_indices() {
+ Some(Indices::U32(values)) => values,
+ _ => Vec::new(),
+ };
+ positions.clear();
+ indices.clear();
+ (positions, indices)
+}
+
+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));
+}
+
+pub(crate) fn empty_shadow_mesh() -> Mesh {
+ let mut mesh = Mesh::new(
+ PrimitiveTopology::TriangleList,
+ RenderAssetUsages::default(),
+ );
+ mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, Vec::<[f32; 3]>::new());
+ mesh.insert_indices(Indices::U32(Vec::new()));
+ mesh
+}
+
+/// 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 `VERTEX_UVS_A` and the fragment's `in.uv` exists.
+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
+}
+
+/// Collect the per-frame ENTITY caster geometry a lane wants into its buffers. `want_creatures` /
+/// `want_environment` select which kinds this caster admits — the CHARACTER lane wants creatures,
+/// the WORLD lane wants entity-resident environment (gameobjects, faded doodads, WMO props). Returns
+/// `(admitted, occluder-rejected)` for the trace. Range-gates before any vertex work. The `parts`
+/// query is written inline (not aliased) so a lane can pass its own identically-typed query.
+#[allow(clippy::too_many_arguments)]
+pub(crate) fn collect_entity_geometry(
+ parts: &Query<
+ (
+ &PickMesh,
+ &ModelPart,
+ Option<&GlobalTransform>,
+ Option<&RigPart>,
+ &ShadowOccluder,
+ Option<&MeshMaterial3d>,
+ ),
+ Without,
+ >,
+ rigs: &Query<&RigSkin>,
+ palettes: &RigPalettes,
+ want_creatures: bool,
+ want_environment: bool,
+ light_position: Vec3,
+ creature_reach: f32,
+ doodad_reach: f32,
+ positions: &mut Vec<[f32; 3]>,
+ indices: &mut Vec,
+) -> (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, want_creatures, want_environment) {
+ continue;
+ }
+ // The content half of the visibility law (toggles, far clip, distance fade, material
+ // alpha) — NOT `InheritedVisibility`, which folds camera-orientation verdicts a shadow
+ // caster must ignore (a tree behind the camera still blocks the sun).
+ if !occluder.0 {
+ rejected += 1;
+ continue;
+ }
+ let reach = match part.kind {
+ ModelKind::Creature | ModelKind::GameObject => creature_reach,
+ ModelKind::Doodad | ModelKind::Wmo => doodad_reach,
+ };
+ let anchor = global.map(GlobalTransform::translation).or_else(|| {
+ rig_part
+ .and_then(|rig_part| rigs.get(rig_part.0).ok())
+ .and_then(|rig| palettes.slot_origin(rig.slot))
+ });
+ let Some(anchor) = anchor else { continue };
+ if anchor.distance_squared(light_position) > reach * reach {
+ continue;
+ }
+ admitted += 1;
+ let base = positions.len() as u32;
+ if let Some(rig_part) = rig_part {
+ append_skinned(pick, rig_part, rigs, palettes, positions);
+ } else if let Some(global) = global {
+ positions.extend(
+ pick.0
+ .positions
+ .iter()
+ .map(|p| global.transform_point(wow_to_bevy(*p)).to_array()),
+ );
+ }
+ let added = positions.len() as u32 - base;
+ if added != 0 {
+ append_triangles(&pick.0.indices, added, base, indices);
+ }
+ }
+ (admitted, rejected)
+}
+
+/// The per-frame entity caster's admit law. CHARACTERS (creatures/players/mounts) are opaque +
+/// alpha-test (hair/fringe cards are small, so casting them solid doesn't box out). Entity-resident
+/// ENVIRONMENT (gameobjects, faded doodads, WMO props) is OPAQUE only — a foliage leaf-card cast
+/// solid becomes a box; retained-world cutout is cast leaf-shaped by the world lane's alpha-tested
+/// material instead. Additive/transparent geometry never casts a solid shadow.
+fn casts_realtime_shadow(
+ kind: ModelKind,
+ blend: ModelBlend,
+ want_creatures: bool,
+ want_environment: bool,
+) -> bool {
+ match kind {
+ ModelKind::Creature => {
+ want_creatures && matches!(blend, ModelBlend::Opaque | ModelBlend::AlphaTest)
+ }
+ _ => want_environment && matches!(blend, ModelBlend::Opaque),
+ }
+}
+
+fn append_skinned(
+ pick: &PickMesh,
+ rig_part: &RigPart,
+ rigs: &Query<&RigSkin>,
+ palettes: &RigPalettes,
+ positions: &mut Vec<[f32; 3]>,
+) {
+ let Ok(rig) = rigs.get(rig_part.0) else {
+ return;
+ };
+ let Some(palette) = palettes.world_palette(rig.slot, rig.bones() as usize) else {
+ return;
+ };
+ for (index, position) in pick.0.positions.iter().enumerate() {
+ // A vertex without skinning data still gets an entry, or `positions` shortens against the
+ // index buffer and rewires later triangles. Seat it on the root matrix.
+ let (Some(joints), Some(weights)) = (pick.0.joints.get(index), pick.0.weights.get(index))
+ else {
+ let fallback = palette
+ .get(0)
+ .map(|matrix| matrix.transform_point3(wow_to_bevy(*position)))
+ .unwrap_or(Vec3::ZERO);
+ positions.push(fallback.to_array());
+ continue;
+ };
+ let mut skinned = Vec3::ZERO;
+ for lane in 0..4 {
+ if weights[lane] > 0.0 {
+ if let Some(matrix) = palette.get(joints[lane] as usize) {
+ skinned += matrix.transform_point3(wow_to_bevy(*position)) * weights[lane];
+ }
+ }
+ }
+ positions.push(skinned.to_array());
+ }
+}
+
+#[cfg(test)]
+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,
+ MAX_SHADOW_SUN_ELEVATION, MIN_SHADOW_SUN_ELEVATION, SHADOW_REACH_CAP, STATIC_REBUILD_STEP,
+ SUN_SNAP_RADIANS,
+ };
+ use benilla_formats::ModelBlend;
+ use benilla_world::model_render::ModelKind;
+ use bevy::prelude::{Quat, Vec3};
+
+ #[test]
+ fn each_lane_admits_only_its_own_kinds() {
+ // The character lane wants creatures (opaque + alpha-test), not environment.
+ assert!(casts_realtime_shadow(ModelKind::Creature, ModelBlend::Opaque, true, false));
+ assert!(casts_realtime_shadow(ModelKind::Creature, ModelBlend::AlphaTest, true, false));
+ assert!(!casts_realtime_shadow(ModelKind::Creature, ModelBlend::Blend, true, false));
+ assert!(!casts_realtime_shadow(ModelKind::Doodad, ModelBlend::Opaque, true, false));
+ // The world lane wants environment (opaque only), not creatures.
+ for kind in [ModelKind::GameObject, ModelKind::Doodad, ModelKind::Wmo] {
+ assert!(casts_realtime_shadow(kind, ModelBlend::Opaque, false, true));
+ assert!(!casts_realtime_shadow(kind, ModelBlend::AlphaTest, false, true));
+ }
+ assert!(!casts_realtime_shadow(ModelKind::Creature, ModelBlend::Opaque, false, true));
+ }
+
+ #[test]
+ fn the_moving_sun_is_clamped_in_elevation_but_keeps_its_bearing() {
+ // A SET sun (to-sun below the horizon) is lifted to the floor; travel still points DOWN.
+ let set = Vec3::new(0.6, -0.5, 0.6).normalize();
+ let t = shadow_sun_travel(set);
+ assert!(t.y < 0.0, "travel points down even for a set sun");
+ assert!(
+ ((-t.y).asin() - MIN_SHADOW_SUN_ELEVATION).abs() < 1e-3,
+ "a set sun is pinned to the floor elevation"
+ );
+ // A near-ZENITH sun is capped so `looking_to(travel, Y)` never degenerates.
+ let high = Vec3::new(0.02, 0.999, 0.02).normalize();
+ assert!(
+ ((-shadow_sun_travel(high).y).asin() - MAX_SHADOW_SUN_ELEVATION).abs() < 1e-2,
+ "a near-zenith sun is capped at the ceiling elevation"
+ );
+ // AZIMUTH preserved: a sun bearing due +X throws its travel toward -X.
+ let east = Vec3::new(1.0, 0.4, 0.0).normalize();
+ let te = shadow_sun_travel(east);
+ assert!(
+ te.x < 0.0 && te.z.abs() < 1e-3,
+ "travel points away from the sun's horizontal bearing"
+ );
+ assert_eq!(shadow_sun_travel(Vec3::ZERO), Vec3::NEG_Z);
+ }
+
+ #[test]
+ fn the_shadow_sun_snaps_on_first_write_holds_under_drift_and_rearms() {
+ let held = Vec3::new(0.3, -0.8, 0.52).normalize();
+ assert!(sun_snap_due(None, held), "first write always fires");
+ let axis = Vec3::Y;
+ let small = Quat::from_axis_angle(axis, SUN_SNAP_RADIANS * 0.5) * held;
+ assert!(!sun_snap_due(Some(held), small), "sub-threshold drift holds");
+ let large = Quat::from_axis_angle(axis, SUN_SNAP_RADIANS * 2.0) * held;
+ assert!(sun_snap_due(Some(held), large), "over-threshold snaps");
+ assert!(!sun_snap_due(Some(large), large), "identical direction never rewrites");
+ }
+
+ #[test]
+ fn shadow_reach_follows_the_sun_and_is_capped() {
+ let diagonal = Vec3::new(1.0, -1.0, 0.0).normalize();
+ 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);
+ }
+
+ #[test]
+ fn an_out_of_range_index_drops_its_whole_triangle_and_no_others() {
+ let mut out = Vec::new();
+ append_triangles(&[0, 1, 2, 1, 2, 9, 2, 0, 1], 3, 10, &mut out);
+ assert_eq!(out, vec![10, 11, 12, 12, 10, 11]);
+ }
+}
diff --git a/crates/benilla-app/src/world_shadow.rs b/crates/benilla-app/src/world_shadow.rs
index 67682117..8d963951 100644
--- a/crates/benilla-app/src/world_shadow.rs
+++ b/crates/benilla-app/src/world_shadow.rs
@@ -1,93 +1,254 @@
-//! MONKEY (world shadows): the WORLD shadow lane — a self-contained, plug-and-play module.
+//! The WORLD shadow lane — a thin plug-in on top of [`super::shadow_core`], equal to and independent
+//! of [`super::character_shadow`].
//!
-//! 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.
+//! It owns the shadows cast by the WORLD: the retained `static_gx` geometry (trees + buildings, cast
+//! solid), the alpha-tested foliage (leaf canopies, one caster per leaf texture), and the per-frame
+//! ENTITY-resident environment (gameobjects, distance-faded doodads, WMO props). When `worldShadows`
+//! is on it declares demand to the shared rig and, while the rig is live, builds those casters using
+//! the rig's shared material + per-frame facts ([`ShadowFrame`]). It also drives the terrain
+//! MCSH-suppression flag ([`WorldShadowActive`]) — baked terrain shadows switch off only for THIS
+//! lane. Remove this module + its plugin registration + the `worldShadows` cvar and the character
+//! lane is untouched.
//!
-//! 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.
+//! Cadence: the static world doesn't move, so its solid + cutout casters rebuild only on camera
+//! drift ([`STATIC_REBUILD_STEP`]); the environment-entity caster (doodads fade in/out, gameobjects
+//! spawn) is collected fresh every frame like the character lane.
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::pbr::{Material, MaterialPipeline, MaterialPlugin, MeshMaterial3d};
use bevy::platform::collections::HashSet;
use bevy::prelude::*;
+use bevy::reflect::TypePath;
+use bevy::render::render_resource::{
+ AsBindGroup, RenderPipelineDescriptor, SpecializedMeshPipelineError,
+};
+use bevy::shader::ShaderRef;
+use benilla_assets::materials::WowModelMaterial;
+use benilla_world::billboard::BillboardCard;
+use benilla_world::interact::PickMesh;
+use benilla_world::lighting::WorldShadowActive;
+use benilla_world::model_render::{ModelPart, ShadowOccluder};
+use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
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,
+use crate::shadow_core::{
+ collect_entity_geometry, empty_cutout_mesh, empty_shadow_mesh, restore_mesh_buffers,
+ shadow_trace, spawn_solid_caster, take_mesh_buffers, ShadowCaster, ShadowDemand, ShadowFrame,
+ ShadowSet, PLAYER_SHADOW_LAYER, STATIC_REBUILD_STEP,
};
+use crate::video::VideoConfig;
-/// 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
+pub(crate) struct WorldShadowPlugin;
+
+impl Plugin for WorldShadowPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_plugins(MaterialPlugin::::default())
+ .init_resource::()
+ .add_systems(Last, update_world_shadows.in_set(ShadowSet::Lanes));
+ }
+}
+
+/// The alpha-tested twin of [`ShadowCasterMaterial`]: same invisible-in-forward proxy, but its
+/// prepass fragment samples a leaf sheet and discards transparent texels so cutout FOLIAGE casts a
+/// leaf-shaped silhouette instead of the solid box a positions-only proxy throws. One instance per
+/// distinct leaf texture. `AlphaMode::Mask` sets Bevy's `MAY_DISCARD` key and the EXPLICIT
+/// `prepass_fragment_shader` makes the otherwise depth-only directional-shadow pass run a fragment —
+/// the exact pair Bevy's prepass specializer requires to alpha-test in the shadow map.
+#[derive(Asset, AsBindGroup, TypePath, Debug, Clone)]
+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).
+ #[texture(0)]
+ #[sampler(1)]
+ 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()
}
- // 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
+ 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; applies to the shadow (prepass) pipeline too.
+ descriptor.primitive.cull_mode = None;
+ Ok(())
+ }
+}
+
+/// One live alpha-tested foliage caster: its layer-31 entity, its per-texture mesh, and the material
+/// carrying that one leaf sheet. Keyed by the leaf texture's `AssetId` so it persists across rebuilds.
+struct CutoutCaster {
+ entity: Entity,
+ mesh: Handle,
+ material: Handle,
+}
+
+/// The world lane's retained casters.
+#[derive(Resource, Default)]
+struct WorldLane {
+ /// The cached static caster — the retained `static_gx` world (trees + buildings), rebuilt on drift.
+ static_caster: Option,
+ static_mesh: Option>,
+ static_rebuilt_at: Option,
+ /// The alpha-tested foliage casters — one per distinct leaf texture.
+ cutout: bevy::platform::collections::HashMap, CutoutCaster>,
+ /// The per-frame ENTITY-resident environment caster (gameobjects, faded doodads, WMO props).
+ env_caster: Option,
+ env_mesh: Option>,
+ env_traced: (u32, u32, u32),
+}
+
+#[allow(clippy::too_many_arguments)]
+fn update_world_shadows(
+ video: Res,
+ mut demand: ResMut,
+ frame: Res,
+ mut lane: ResMut,
+ mut commands: Commands,
+ mut meshes: ResMut>,
+ mut cutout_materials: ResMut>,
+ // The retained static world — `Option` because it exists only when the retained pass is armed.
+ gx: Option>,
+ // The terrain MCSH-suppression flag (this lane owns it).
+ mut world_active: ResMut,
+ parts: Query<
+ (
+ &PickMesh,
+ &ModelPart,
+ Option<&GlobalTransform>,
+ Option<&RigPart>,
+ &ShadowOccluder,
+ Option<&MeshMaterial3d>,
+ ),
+ Without,
+ >,
+ rigs: Query<&RigSkin>,
+ palettes: Res,
+) {
+ // Declare demand so the shared rig stays up while this lane is on (rig reads it next frame).
+ demand.0 = demand.0 || video.world_shadows;
+ // The lane is casting when the rig is live AND its cvar is on. The MCSH terrain-shadow
+ // suppression keys on exactly this (a character-only shadow sun must leave baked MCSH alone).
+ let world_on = frame.active && video.world_shadows;
+ if world_active.0 != world_on {
+ world_active.0 = world_on;
+ }
+
+ if !world_on {
+ teardown(&mut lane, &mut commands, &mut meshes, &mut cutout_materials);
+ return;
+ }
+ let Some(material) = frame.material.clone() else {
+ return;
+ };
+
+ // (1) The per-frame ENTITY-resident environment caster (opaque doodads/gameobjects/WMO props).
+ if lane.env_caster.is_none() {
+ let mesh = meshes.add(empty_shadow_mesh());
+ let entity = spawn_solid_caster(&mut commands, mesh.clone(), material.clone());
+ lane.env_caster = Some(entity);
+ lane.env_mesh = Some(mesh);
+ }
+ if let Some(handle) = lane.env_mesh.clone() {
+ if let Some(mesh) = meshes.get_mut(&handle) {
+ let (mut positions, mut indices) = take_mesh_buffers(mesh);
+ let (admitted, rejected) = collect_entity_geometry(
+ &parts,
+ &rigs,
+ &palettes,
+ false, // want_creatures (the character lane's job)
+ true, // want_environment
+ frame.light_position,
+ frame.entity_reach,
+ frame.tall_reach,
+ &mut positions,
+ &mut indices,
+ );
+ let tris = (indices.len() / 3) as u32;
+ restore_mesh_buffers(mesh, positions, indices);
+ if shadow_trace() {
+ let now = (tris, admitted, rejected);
+ if lane.env_traced != now {
+ info!(
+ "shadow-trace: world-entity {} tris | env admitted {} / rejected {} (was {:?})",
+ tris, admitted, rejected, lane.env_traced
+ );
+ lane.env_traced = now;
+ }
+ }
+ }
+ }
+
+ // (2) The cached static world (trees + buildings) + alpha-tested foliage — rebuilt on drift.
+ let Some(gx) = gx.as_ref() else {
+ return;
+ };
+ // Ensure the static SOLID caster.
+ if lane.static_caster.is_none() {
+ let mesh = meshes.add(empty_shadow_mesh());
+ let entity = spawn_solid_caster(&mut commands, mesh.clone(), material);
+ lane.static_caster = Some(entity);
+ lane.static_mesh = Some(mesh);
+ lane.static_rebuilt_at = None;
+ }
+ let due = lane
.static_rebuilt_at
- .map_or(true, |at| at.distance(light_position) > STATIC_REBUILD_STEP);
+ .map_or(true, |at| at.distance(frame.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() {
+ // Solid pass — the retained world's opaque geometry (minus the cutout foliage it skips).
+ if let Some(handle) = lane.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;
+ gx.append_shadow_triangles(
+ frame.light_position,
+ frame.tall_reach,
+ &mut positions,
+ &mut indices,
+ );
+ let tris = (indices.len() / 3) as u32;
restore_mesh_buffers(mesh, positions, indices);
- runtime.static_rebuilt_at = Some(light_position);
+ lane.static_rebuilt_at = Some(frame.light_position);
if shadow_trace() {
info!(
"shadow-trace: static rebuild — {} tris within {:.0}yd",
- static_tris, tall_reach
+ tris, frame.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);
+ // Alpha-tested foliage pass — the cutout leaf cards, grouped one caster per leaf texture.
+ let buckets = gx.collect_cutout_shadow_triangles(frame.light_position, frame.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) {
+ if !lane.cutout.contains_key(&bucket.texture_id) {
let mesh = meshes.add(empty_cutout_mesh());
let material = cutout_materials.add(CutoutShadowCasterMaterial {
leaf: bucket.texture.clone(),
@@ -101,31 +262,28 @@ pub(crate) fn update(
RenderLayers::layer(PLAYER_SHADOW_LAYER),
NoFrustumCulling,
NotShadowReceiver,
- WorldShadowCaster,
+ ShadowCaster,
))
.id();
- runtime
- .static_cutout
+ lane.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();
+ let mesh_handle = lane.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
+ // Despawn casters whose leaf texture left the reach, so the set stays bounded across zones.
+ let stale: Vec> = lane
+ .cutout
.keys()
.filter(|id| !seen.contains(*id))
.copied()
.collect();
for id in stale {
- if let Some(caster) = runtime.static_cutout.remove(&id) {
+ if let Some(caster) = lane.cutout.remove(&id) {
commands.entity(caster.entity).despawn();
meshes.remove(caster.mesh.id());
cutout_materials.remove(caster.material.id());
@@ -135,30 +293,34 @@ pub(crate) fn update(
info!(
"shadow-trace: cutout rebuild — {} tris across {} leaf textures",
cutout_tris,
- runtime.static_cutout.len()
+ lane.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,
+/// Tear down every world-lane caster (static solid, all foliage casters, the environment caster).
+fn teardown(
+ lane: &mut WorldLane,
commands: &mut Commands,
meshes: &mut Assets,
cutout_materials: &mut Assets,
) {
- if let Some(entity) = runtime.static_caster.take() {
+ if let Some(entity) = lane.static_caster.take() {
commands.entity(entity).despawn();
}
- if let Some(handle) = runtime.static_mesh.take() {
+ if let Some(handle) = lane.static_mesh.take() {
meshes.remove(handle.id());
}
- runtime.static_rebuilt_at = None;
- for (_texture, caster) in runtime.static_cutout.drain() {
+ lane.static_rebuilt_at = None;
+ for (_texture, caster) in lane.cutout.drain() {
commands.entity(caster.entity).despawn();
meshes.remove(caster.mesh.id());
cutout_materials.remove(caster.material.id());
}
+ if let Some(entity) = lane.env_caster.take() {
+ commands.entity(entity).despawn();
+ }
+ if let Some(handle) = lane.env_mesh.take() {
+ meshes.remove(handle.id());
+ }
}
diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl
index 5aa629db..717c694c 100644
--- a/crates/benilla-assets/src/shaders/terrain.wgsl
+++ b/crates/benilla-assets/src/shaders/terrain.wgsl
@@ -41,6 +41,11 @@
// 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;
+const SHADOW_EDGE_BAND: f32 = 14.0;
+
// Per-tile Vec4 uniforms packed into ONE buffer (binding 106) — the field order here MUST match the
// Rust `TerrainExtension` struct. Light + fog live in the shared global-light storage buffer (below);
// what's left is just the per-tile layer tiling factor.
@@ -312,6 +317,15 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 {
}
}
}
+ // MONKEY: soften the realtime shadow before it darkens the ground — an EDGE fade toward the
+ // cascade's max distance (no hard pop at the resolve boundary) and a NIGHT fade by the real sun
+ // height (`fog_params.z`, 1 by day → 0 at night, so shadows vanish as the sun sets). Both
+ // 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 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;
// STEP 4: MCSH baked shadow. On the reference path (pixelShaders+specular) terrain is ONE pass
@@ -332,8 +346,13 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 {
// 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.
+ // The world lane suppresses baked MCSH — but only by `sun_shadow_strength`, so as the realtime
+ // world shadow fades at night the baked MCSH fades back IN. By day (strength 1) MCSH is fully
+ // replaced; at night (strength 0) the authored bake returns; dusk crossfades. Character-only
+ // (lane flag off) never suppresses MCSH.
let world_shadow_lane = wow_light.sh_c16.w > 0.5;
- let shadow_lit_eff = select(shadow_lit, 1.0, world_shadow_lane);
+ let mcsh_suppress = select(0.0, sun_shadow_strength, world_shadow_lane);
+ let shadow_lit_eff = mix(shadow_lit, 1.0, mcsh_suppress);
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-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl
index 8ac68859..8036eed9 100644
--- a/crates/benilla-assets/src/shaders/wow_model.wgsl
+++ b/crates/benilla-assets/src/shaders/wow_model.wgsl
@@ -115,6 +115,11 @@ 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;
+const SHADOW_EDGE_BAND: f32 = 14.0;
+
// The shared global light (lighting::global_light): ONE storage buffer every material reads, updated
// once/frame in place — replaces the per-material light/fog uniforms the old apply_wow_lighting re-pushed
// each frame. The model reads rows 0-2 (ambient/diffuse/sun) + fog, plus rows 6-12: the disassembled
@@ -1014,6 +1019,14 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
}
}
}
+ // MONKEY: soften the realtime shadow like the terrain — an EDGE fade toward the cascade's max
+ // distance + a NIGHT fade by the real sun height (`fog_params.z`, 1 by day → 0 at night). Both
+ // lighten `player_shadow` toward 1.0 (no shadow), so shadows on models fade in at range and
+ // 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);
+ 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,
// point-lit props, and shadow-side characters globally too dark.
diff --git a/crates/benilla-world/src/lighting/global_light.rs b/crates/benilla-world/src/lighting/global_light.rs
index 94a1e816..e97fa583 100644
--- a/crates/benilla-world/src/lighting/global_light.rs
+++ b/crates/benilla-world/src/lighting/global_light.rs
@@ -275,6 +275,14 @@ pub(super) fn per_frame_blob_bytes() -> u64 {
/// std430 blob. The `.w` lanes carry the faithful invariants the shaders expect (Mod2x 1.0, clamp on,
/// terrain shininess 20, fog-enable, farclip wall); the model SH coeffs and both water swatches are
/// derived here once per frame (they used to be recomputed + pushed per-material in `apply_wow_lighting`).
+/// MONKEY (night fade): realtime-shadow day strength from the celestial sun's height
+/// (`sin(elevation)`): 0 at or below the horizon, ramping to 1 by ~12° so shadows fade out at dusk
+/// and in at dawn. A smoothstep for a soft knee rather than a hard switch at the horizon.
+fn sun_shadow_strength(sun_height: f32) -> f32 {
+ let t = (sun_height / 0.208).clamp(0.0, 1.0); // 0.208 ≈ sin(12°)
+ t * t * (3.0 - 2.0 * t)
+}
+
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
fn build_light_data(
light: Res,
@@ -327,6 +335,11 @@ fn build_light_data(
// MCSH terrain-shadow suppression in `terrain.wgsl` keys on THIS — not on the mere presence of
// a shadow sun — so character-only shadows (sun present, world lane off) keep the baked MCSH.
rows[12][3] = if world_shadow.0 { 1.0 } else { 0.0 };
+ // MONKEY (night fade): realtime-shadow strength by the REAL celestial sun height, packed into
+ // the free `fog_params.z` lane. The receivers (terrain/model) lighten their shadow term by it,
+ // so shadows soften and vanish at night; the shadow basis is separately clamped to 18° so a low
+ // sun still casts the right DIRECTION.
+ rows[5][2] = sun_shadow_strength(l.celestial_dir.y);
// The dynamic point-light table (decision 0278): every spawned point light within
// [`POINT_PACK_RADIUS`] of the camera, nearest-first when over capacity — the VERTEX stages of
// `terrain.wgsl`/`wow_model.wgsl` walk it for the Gouraud point term (bevy's clusterable buffer
diff --git a/crates/benilla-world/src/lighting/mod.rs b/crates/benilla-world/src/lighting/mod.rs
index 02a0afe3..874da73a 100644
--- a/crates/benilla-world/src/lighting/mod.rs
+++ b/crates/benilla-world/src/lighting/mod.rs
@@ -166,6 +166,13 @@ pub struct WowLighting {
}
impl WowLighting {
+ /// The visible **celestial sun** direction (camera→sun, Bevy space) — the body that genuinely
+ /// rises and sets over the day. Exposed for the shadow rig, which aims its basis at this MOVING
+ /// sun (with an elevation clamp) rather than the near-fixed lighting `sun_dir`.
+ pub fn celestial_dir(&self) -> Vec3 {
+ self.celestial_dir
+ }
+
/// Per-kind **water swatch endpoints**: `(shallow_rgb, deep_rgb, shallow_alpha, deep_alpha)`. The
/// from-above depth swatch is a plain **2-endpoint linear lerp** of the zone's dedicated `Light.dbc`
/// water rows — IntBand 16/17 (river/lake) or 14/15 (ocean), **RAW** (no ×0.711) — by the per-vertex
From b9c84be4f44663a11eee82656a8f505a870e9769 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Sat, 5 Sep 2026 17:13:49 +0100
Subject: [PATCH 06/17] MONKEY: shadow render-distance slider (shadowDistance)
Make the 80 yd realtime-shadow range a runtime slider (Options -> Graphics ->
Shadow Distance, 40-200 yd, default 80). Confirmed in-game.
- shadowDistance cvar -> VideoConfig.shadow_distance, clamped to
shadow_core::SHADOW_DISTANCE_RANGE. VideoConfig drops Eq (f32 field).
- shadow_core reads it: the cascade maximum_distance + the caster-collection
reaches. The CascadeShadowConfig is re-fit only when the slider changes (not
per frame, which would defeat the texel snap and crawl every edge).
- Bridged to a benilla-world ShadowDistance resource; global_light packs it into
the free row-19 light lane so the receiver EDGE FADE fades at the cascade's
actual max distance, whatever the slider is set to (terrain _wmo_fog[1].z /
model wmo_fog_params.z). The fixed SHADOW_RANGE consts are gone.
- Options: a deferred slider row (commits on Apply so a drag doesn't re-fit the
cascade + recollect casters every tick).
Co-Authored-By: Claude Opus 4.8
---
crates/benilla-app/assets/ui/OptionsFrame.xml | 25 +++++++-
crates/benilla-app/src/cvars.rs | 16 ++++-
crates/benilla-app/src/shadow_core.rs | 64 +++++++++++++++----
.../src/ui_script/options_tests.rs | 6 +-
crates/benilla-app/src/video.rs | 8 ++-
.../benilla-assets/src/shaders/terrain.wgsl | 9 +--
.../benilla-assets/src/shaders/wow_model.wgsl | 9 +--
.../src/lighting/global_light.rs | 20 ++++++
crates/benilla-world/src/lighting/mod.rs | 4 +-
9 files changed, 134 insertions(+), 27 deletions(-)
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,
// MONKEY (world shadows): the `worldShadows` lane flag, packed into `sh_c16.w` for the MCSH gate.
world_shadow: Res,
+ // MONKEY (distance slider): the realtime-shadow render distance, packed for the edge fade.
+ shadow_distance: Res,
mut last_dump: Local,
mut last_rows_dump: Local,
) {
@@ -340,6 +356,10 @@ fn build_light_data(
// so shadows soften and vanish at night; the shadow basis is separately clamped to 18° so a low
// sun still casts the right DIRECTION.
rows[5][2] = sun_shadow_strength(l.celestial_dir.y);
+ // MONKEY (distance slider): the realtime-shadow render distance (yd), packed into the free
+ // `_wmo_fog[1].z` / `wmo_fog_params.z` lane (row 19). The receivers' edge fade reads it so the
+ // shadow fades at the cascade's actual `maximum_distance`, whatever the slider is set to.
+ rows[19][2] = shadow_distance.0;
// The dynamic point-light table (decision 0278): every spawned point light within
// [`POINT_PACK_RADIUS`] of the camera, nearest-first when over capacity — the VERTEX stages of
// `terrain.wgsl`/`wow_model.wgsl` walk it for the Gouraud point term (bevy's clusterable buffer
diff --git a/crates/benilla-world/src/lighting/mod.rs b/crates/benilla-world/src/lighting/mod.rs
index 874da73a..7e216181 100644
--- a/crates/benilla-world/src/lighting/mod.rs
+++ b/crates/benilla-world/src/lighting/mod.rs
@@ -17,7 +17,9 @@ mod prop_probes; // the per-instance interior-prop SH probe table (slot ↔ Mesh
mod resolve; // the per-frame time-of-day sample into WowLighting + the WMO interior-fog crossfade
mod sh; // the model SH light-probe coefficient math
pub use blob::LightBlob;
-pub use global_light::{new_shared_light_buffer, LightRooms, SharedLightBuffer, WorldShadowActive};
+pub use global_light::{
+ new_shared_light_buffer, LightRooms, SharedLightBuffer, ShadowDistance, WorldShadowActive,
+};
pub use prop_probes::{PropProbeSlot, PropProbes, MAX_PROP_PROBES};
// The std430 layout itself — row indices, byte sizes, region offsets and the folds that fill them
// — stays in the crate. Off-world producers state values through `LightBlob` and never a row index
From 44e09effba5d180ed8555f3f86110bbe13c2ad1c Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Sat, 5 Sep 2026 17:48:06 +0100
Subject: [PATCH 07/17] MONKEY: keep the blob shadow for indoor units under
Real Character Shadows
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
An indoor unit was left with NO ground shadow when characterShadows was on: the
realtime sun shadow is gated out of WMO interiors (correct — no sun reaches a
sealed room), and the oval blob was hidden for every unit. So indoor characters
floated. Now the blob is hidden only for OUTDOOR units (where the realtime cast
owns them); INDOOR units (UnitWmoRoom::room() = Some) keep the soft oval, so they
stay grounded. Folded the old blanket early-return into a per-unit gate in the
projection loop. Confirmed in-game. Default path (both cvars off) unchanged.
Co-Authored-By: Claude Opus 4.8
---
crates/benilla-app/src/blob_shadow.rs | 37 +++++++++++++++------------
1 file changed, 21 insertions(+), 16 deletions(-)
diff --git a/crates/benilla-app/src/blob_shadow.rs b/crates/benilla-app/src/blob_shadow.rs
index c511e27f..5acf0792 100644
--- a/crates/benilla-app/src/blob_shadow.rs
+++ b/crates/benilla-app/src/blob_shadow.rs
@@ -70,6 +70,7 @@ use benilla_world::model_fade::{fade_alpha, RenderFade};
use benilla_world::particles::buffer::{begin_effect_frame, EffectVertex};
use benilla_world::schedule::WorldStage;
use benilla_world::view::WorldCamera;
+use benilla_world::wmo_portal::UnitWmoRoom;
/// The reference's shadow disc (`Textures\ShadowBlob.blp`, wow-re unit-blob-shadow RE): grayscale
/// radial blob (gray-160 core → white rim) under a binary alpha disc, multiplied onto the ground.
@@ -204,6 +205,10 @@ fn update_shadows(
// pass 2 is not in the reference's scene, so it casts nothing (decision 1277). The
// election writes the ROOT's `Visibility`; this is that verdict after propagation.
Option<&InheritedVisibility>,
+ // …and the unit's WMO room. `Some(room)` = indoors — the realtime SUN shadow is gated
+ // out of interiors, so an INDOOR unit keeps its oval even when `characterShadows` is on
+ // (otherwise it would have no ground shadow at all). Absent / no-room = outdoors.
+ Option<&UnitWmoRoom>,
),
Without,
>,
@@ -220,21 +225,12 @@ fn update_shadows(
// report, answerable from a log instead of a debugger.
mut census_at: Local,
) {
- // `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);
- }
- }
- return;
- }
+ // `characterShadows 1`: the realtime shadow-map path owns OUTDOOR unit shadows — the per-unit
+ // gate in the loop below hides the oval for outdoor units so they don't wear both, while INDOOR
+ // units keep it (the realtime sun shadow is gated out of interiors, so an indoor unit would
+ // otherwise have no ground shadow at all). (This keys on the CHARACTER lane, not the world lane:
+ // the oval is a character's shadow, so `worldShadows` alone must leave it be.) With the cvar off
+ // — the shipped default — that gate is a single false branch and this is the reference path.
let now = time.elapsed_secs();
let census = now >= *census_at;
if census {
@@ -259,12 +255,21 @@ fn update_shadows(
let surface_count = decals.receiver_count();
for (shadow, mut key, mut verts) in &mut shadows {
n_total += 1;
- let Ok((unit, anims, is_self, mount_child, drawn)) = owners.get(shadow.owner) else {
+ let Ok((unit, anims, is_self, mount_child, drawn, room)) = owners.get(shadow.owner) else {
// sync_shadows despawns next frame; keep it cleared meanwhile.
hide(&mut key, &mut verts);
n_no_owner += 1;
continue;
};
+ // `characterShadows`: the realtime cast owns OUTDOOR units, so hide the oval there (no
+ // double shadow). INDOOR units (`UnitWmoRoom::room()` = Some) keep it — the realtime sun
+ // shadow is gated out of interiors, so without this an indoor unit has no ground shadow at
+ // all. Absent room component / no-room claim = outdoors.
+ let indoors = room.is_some_and(|r| r.room().is_some());
+ if video.character_shadows && !indoors {
+ hide(&mut key, &mut verts);
+ continue;
+ }
// An owner that is not drawn casts nothing. The director's report from inside Caverns of
// Time (decision 1277): the exterior election had correctly stopped drawing the Tanaris
// mobs overhead, and their shadows carried on being projected onto the cavern floor,
From a0f65f26ae9bbbde668765310ec27699dd85ef61 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Sat, 5 Sep 2026 18:50:52 +0100
Subject: [PATCH 08/17] MONKEY: shadow extension hook (benilla::shadow_hook) so
receivers stay untouched
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Extract the realtime directional-shadow term (fetch + edge fade + night fade)
into ONE importable WGSL library, benilla::shadow_hook, and route all three
receivers through it. Adding or changing the realtime shadow now touches only
shadow_hook.wgsl; terrain.wgsl / wow_model.wgsl / static_gx.wgsl (the shaders
that blanked the terrain when hand-edited) are no longer edited per shadow
feature. Confirmed in-game (rendering unchanged).
- shadow_hook.wgsl (NEW): `realtime_shadow(sample_pos, normal, view_z, cam_dist,
shadow_range, night) -> factor`. No-op whenever no shadow sun exists (the only
Bevy DirectionalLight benilla spawns is the rig's, so n_directional_lights == 0
exactly when shadows are off) — no shader-def toggle needed. Registered via
bevy `load_shader_library!` in benilla-assets so `#import benilla::shadow_hook`
resolves across crates (verified: the model pipeline composes it cleanly).
- terrain / wow_model: replace the inline fetch+fade with the hook call; keep
their own downstream gating (MCSH suppression + spec gate; rig-skin sample
point + interior exclusion + ambient-preserving apply). Behavior-preserving.
- static_gx: routed through the hook too — tree/building surfaces now get the
SAME edge + night fade terrain/models already had (they lacked it before).
- The duplicated SHADOW_EDGE_BAND const now lives once, in the hook.
Co-Authored-By: Claude Opus 4.8
---
crates/benilla-assets/src/materials.rs | 8 +-
.../src/shaders/shadow_hook.wgsl | 57 ++++++++++++
.../benilla-assets/src/shaders/terrain.wgsl | 43 ++++-----
.../benilla-assets/src/shaders/wow_model.wgsl | 87 ++++++++-----------
.../benilla-world/src/shaders/static_gx.wgsl | 27 +++---
5 files changed, 130 insertions(+), 92 deletions(-)
create mode 100644 crates/benilla-assets/src/shaders/shadow_hook.wgsl
diff --git a/crates/benilla-assets/src/materials.rs b/crates/benilla-assets/src/materials.rs
index 8d09ad38..e1137937 100644
--- a/crates/benilla-assets/src/materials.rs
+++ b/crates/benilla-assets/src/materials.rs
@@ -35,9 +35,9 @@ use bevy::render::render_resource::{
AsBindGroup, BlendComponent, BlendFactor, BlendOperation, BlendState, Buffer, ColorWrites,
CompareFunction, RenderPipelineDescriptor, SpecializedMeshPipelineError,
};
-use bevy::shader::ShaderRef;
+use bevy::shader::{load_shader_library, ShaderRef};
-/// Compile the four WGSL files into the binary and register them under
+/// Compile the WGSL files into the binary and register them under
/// `embedded://benilla_assets/shaders/…`. Call **after** Bevy's `AssetPlugin` (it fills the
/// registry that plugin creates); [`crate::register_asset_loaders`] already does.
pub fn register_shaders(app: &mut App) {
@@ -45,6 +45,10 @@ pub fn register_shaders(app: &mut App) {
bevy::asset::embedded_asset!(app, "shaders/wow_model.wgsl");
bevy::asset::embedded_asset!(app, "shaders/wdl.wgsl");
bevy::asset::embedded_asset!(app, "shaders/liquid.wgsl");
+ // MONKEY (shadow hook): register the importable shadow-contribution library `benilla::shadow_hook`
+ // (loaded eagerly so `#import benilla::shadow_hook` resolves in terrain/model/static_gx). This is
+ // the ONE place the realtime directional-shadow term lives; the receivers just call into it.
+ load_shader_library!(app, "shaders/shadow_hook.wgsl");
}
/// The WDL far-band shader's source, for the law tests that live beside the renderer rather than
diff --git a/crates/benilla-assets/src/shaders/shadow_hook.wgsl b/crates/benilla-assets/src/shaders/shadow_hook.wgsl
new file mode 100644
index 00000000..265bb613
--- /dev/null
+++ b/crates/benilla-assets/src/shaders/shadow_hook.wgsl
@@ -0,0 +1,57 @@
+#define_import_path benilla::shadow_hook
+
+// MONKEY (shadow extension hook): the ONE place the realtime directional-shadow term is computed.
+//
+// benilla's receivers — `terrain.wgsl`, `wow_model.wgsl`, `static_gx.wgsl` — call `realtime_shadow`
+// instead of each hand-rolling the fetch + fades. Adding or changing the realtime shadow now touches
+// THIS file only; the three receiver shaders (the ones that blanked the terrain when edited by hand)
+// stay untouched. The receivers keep their own downstream gating (terrain's MCSH suppression + spec
+// gate, the model's interior/rig-skin exclusion + ambient-preserving apply, static_gx's ambient arm)
+// — those are lighting composition, not shadow sampling, so they belong to each receiver.
+//
+// It is a no-op whenever no shadow sun exists: the only Bevy `DirectionalLight` benilla ever spawns
+// is the shadow rig's (`benilla_app::shadow_core`), so `lights.n_directional_lights == 0` exactly
+// when shadows are off, and the loop below never runs. No shader-def toggle is needed.
+
+#import bevy_pbr::{
+ shadows,
+ mesh_view_bindings::lights,
+}
+
+// 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.
+const SHADOW_EDGE_BAND: f32 = 14.0;
+
+// The realtime directional-shadow factor at `sample_pos`: 1.0 = lit, 0.0 = fully shadowed.
+//
+// - `sample_pos` world-space point to sample the shadow map at — a per-fragment position, or a
+// unit's nudged rig anchor (the caller chooses; that is the rig-skin decision).
+// - `normal` receiver normal, for the slope-aware depth bias.
+// - `view_z` view-space z, for cascade selection.
+// - `cam_dist` distance from the camera to the receiver fragment, for the EDGE fade.
+// - `shadow_range` the cascade's max distance (the `shadowDistance` slider) — where the edge fade ends.
+// - `night` the sun-height strength (0 at night .. 1 by day) — the NIGHT fade.
+//
+// Both fades lighten the result toward 1.0 (no shadow). The caller passes `shadow_range`/`night`
+// from its own light buffer (the receivers declare that buffer three incompatible ways, so the hook
+// takes the scalars as arguments rather than reading it).
+fn realtime_shadow(
+ sample_pos: vec4,
+ normal: vec3,
+ view_z: f32,
+ cam_dist: f32,
+ shadow_range: f32,
+ night: f32,
+) -> f32 {
+ var shadow = 1.0;
+ if (lights.n_directional_lights > 0u) {
+ for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
+ if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
+ shadow = shadows::fetch_directional_shadow(light_id, sample_pos, normal, view_z);
+ break;
+ }
+ }
+ }
+ let edge_fade = smoothstep(shadow_range - SHADOW_EDGE_BAND, shadow_range, cam_dist);
+ return 1.0 - (1.0 - shadow) * night * (1.0 - edge_fade);
+}
diff --git a/crates/benilla-assets/src/shaders/terrain.wgsl b/crates/benilla-assets/src/shaders/terrain.wgsl
index 2cf73e30..8f8f87fe 100644
--- a/crates/benilla-assets/src/shaders/terrain.wgsl
+++ b/crates/benilla-assets/src/shaders/terrain.wgsl
@@ -32,6 +32,8 @@
mesh_view_bindings::{lights, view},
shadows,
}
+// MONKEY (shadow hook): the realtime directional-shadow term (fetch + edge/night fade) lives here.
+#import benilla::shadow_hook
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var layer_array: texture_2d_array;
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var alpha_array: texture_2d_array;
@@ -41,11 +43,6 @@
// 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 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
// Rust `TerrainExtension` struct. Light + fog live in the shared global-light storage buffer (below);
// what's left is just the per-tile layer tiling factor.
@@ -302,31 +299,21 @@ fn fragment(in: TerrainVsOut) -> @location(0) vec4 {
// Keep the raw fetch for the specular gate below. The realtime map now contains CHARACTERS
// only; MCSH contains the static world's baked blockers. They are independent occluders, so
// the character map must never replace (and thereby brighten) the authored terrain bake.
- var world_shadow = 1.0;
- if (lights.n_directional_lights > 0u) {
- let view_z = (view.view_from_world * in.world_position).z;
- for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
- if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
- world_shadow = shadows::fetch_directional_shadow(
- light_id,
- in.world_position,
- normalize(in.world_normal),
- view_z,
- );
- break;
- }
- }
- }
- // MONKEY: soften the realtime shadow before it darkens the ground — an EDGE fade toward the
- // cascade's max distance (no hard pop at the resolve boundary) and a NIGHT fade by the real sun
- // height (`fog_params.z`, 1 by day → 0 at night, so shadows vanish as the sun sets). Both
- // lighten `world_shadow` toward 1.0 (no shadow). `sun_shadow_strength` is reused for the MCSH
- // fade-back below.
+ // MONKEY (shadow hook): the realtime shadow — fetch + edge fade (toward the `shadowDistance`
+ // range in `_wmo_fog[1].z`) + night fade (`fog_params.z`) — is computed by `benilla::shadow_hook`.
+ // Terrain keeps only its own MCSH suppression + spec gate below; `sun_shadow_strength` feeds the
+ // MCSH fade-back.
+ let view_z = (view.view_from_world * in.world_position).z;
let cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
- 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 world_shadow = shadow_hook::realtime_shadow(
+ in.world_position,
+ normalize(in.world_normal),
+ view_z,
+ cam_dist,
+ wow_light._wmo_fog[1].z,
+ sun_shadow_strength,
+ );
let primary = in.primary;
// STEP 4: MCSH baked shadow. On the reference path (pixelShaders+specular) terrain is ONE pass
diff --git a/crates/benilla-assets/src/shaders/wow_model.wgsl b/crates/benilla-assets/src/shaders/wow_model.wgsl
index 09fc9543..4f6433ab 100644
--- a/crates/benilla-assets/src/shaders/wow_model.wgsl
+++ b/crates/benilla-assets/src/shaders/wow_model.wgsl
@@ -51,6 +51,8 @@
shadows,
mesh_functions,
}
+// MONKEY (shadow hook): the realtime directional-shadow term (fetch + edge/night fade) lives here.
+#import benilla::shadow_hook
// Our own fragment output, so the SKY lane can force the far depth. Bevy's `forward_io::FragmentOutput`
// is `@location(0) color` and nothing else (bevy_pbr 0.18.1 `forward_io.wgsl`), so this is that struct
@@ -115,11 +117,6 @@ 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 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
// once/frame in place — replaces the per-material light/fog uniforms the old apply_wow_lighting re-pushed
// each frame. The model reads rows 0-2 (ambient/diffuse/sun) + fog, plus rows 6-12: the disassembled
@@ -981,53 +978,43 @@ fn fragment(in: WowVsOut, @builtin(front_facing) is_front: bool) -> WowFragOut {
// through `point_diffuse` below like any exterior entity.
let is_rig = m.sun_scale.x >= 1.5;
let lit = select(select(lit_exterior, lit_interior, is_interior), lit_m2_interior, is_rig);
+ // MONKEY (shadow hook): the realtime shadow (fetch + edge/night fade) is computed by
+ // `benilla::shadow_hook`. This lane keeps only the rig-skin SAMPLE-POINT choice + the
+ // interior/rig exclusion + ambient-preserving apply below.
var player_shadow = 1.0;
- if (lights.n_directional_lights > 0u) {
- let view_z = (view.view_from_world * in.world_position).z;
- for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
- if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
-#ifdef WOW_RIG_SKIN
- // A skinned UNIT samples the map ONCE at its rig origin (between the feet),
- // nudged 2.5 units TOWARD the sun, and the whole unit dims uniformly — the
- // reference's own per-unit response. Per-fragment sampling is wrong here: the
- // caster proxy holds a CPU-skinned copy of this very body, so a body fragment
- // reads its own limbs/weapon as occluders (self-shadow speckle no bias fixes on
- // animated silhouettes). Points along the sun ray share a shadow-map texel, so
- // the nudge only excludes occluders within ~2.5 units of the feet ALONG the ray
- // (a fence hugging the unit) — a building or canopy still lands. Gated on the
- // directional-enable flag so an interior-lit unit never takes an exterior dim.
- if (wow_light.light_sun.w > 0.5) {
- let sun_ray = normalize(wow_light.light_sun.xyz);
- let anchor = wow_light.rig_origin[(fade_tag >> 19u) & 0x7ffu].xyz
- - 2.5 * sun_ray;
- player_shadow = shadows::fetch_directional_shadow(
- light_id,
- vec4(anchor, 1.0),
- vec3(0.0, 1.0, 0.0),
- view_z,
- );
- }
-#else
- player_shadow = shadows::fetch_directional_shadow(
- light_id,
- in.world_position,
- wow_normalize(in.world_normal),
- view_z,
- );
-#endif
- break;
- }
- }
- }
- // MONKEY: soften the realtime shadow like the terrain — an EDGE fade toward the cascade's max
- // distance + a NIGHT fade by the real sun height (`fog_params.z`, 1 by day → 0 at night). Both
- // lighten `player_shadow` toward 1.0 (no shadow), so shadows on models fade in at range and
- // vanish at night. No effect when unshadowed (player_shadow already 1.0) — keeps the
- // `worldShadows 0` path byte-identical.
+ let view_z = (view.view_from_world * in.world_position).z;
let shadow_cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
- 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);
+#ifdef WOW_RIG_SKIN
+ // A skinned UNIT samples the map ONCE at its rig origin (between the feet), nudged 2.5 units
+ // TOWARD the sun, and dims uniformly — the reference's per-unit response. Per-fragment sampling
+ // is wrong here: the caster proxy holds a CPU-skinned copy of this very body, so a body fragment
+ // reads its own limbs/weapon as occluders (self-shadow speckle). Gated on the directional-enable
+ // flag so an interior-lit unit never takes an exterior dim (player_shadow stays 1.0).
+ if (wow_light.light_sun.w > 0.5) {
+ let sun_ray = normalize(wow_light.light_sun.xyz);
+ let anchor = vec4(
+ wow_light.rig_origin[(fade_tag >> 19u) & 0x7ffu].xyz - 2.5 * sun_ray,
+ 1.0,
+ );
+ player_shadow = shadow_hook::realtime_shadow(
+ anchor,
+ vec3(0.0, 1.0, 0.0),
+ view_z,
+ shadow_cam_dist,
+ wow_light.wmo_fog_params.z,
+ wow_light.fog_params.z,
+ );
+ }
+#else
+ player_shadow = shadow_hook::realtime_shadow(
+ in.world_position,
+ wow_normalize(in.world_normal),
+ view_z,
+ shadow_cam_dist,
+ wow_light.wmo_fog_params.z,
+ wow_light.fog_params.z,
+ );
+#endif
// 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,
// point-lit props, and shadow-side characters globally too dark.
diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl
index d02a735e..1c993e20 100644
--- a/crates/benilla-world/src/shaders/static_gx.wgsl
+++ b/crates/benilla-world/src/shaders/static_gx.wgsl
@@ -31,6 +31,8 @@
mesh_view_bindings::{lights, view},
shadows,
}
+// MONKEY (shadow hook): the realtime directional-shadow term (fetch + edge/night fade) lives here.
+#import benilla::shadow_hook
// Group 0 is Bevy's standard mesh-view bind group (view matrices, directional-light records and
// the shadow textures the retained pass reads).
@@ -289,20 +291,21 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 {
// The retained path uses the same Bevy directional shadow map as terrain and entity models.
// `static_gx` used to have no mesh-view shadow bindings, which made every Stormwind WMO act
// as if shadows were disabled even though the caster map was populated.
+ // MONKEY (shadow hook): the realtime shadow (fetch + edge/night fade, via `benilla::shadow_hook`)
+ // — now with the same edge/night fade as terrain + models. Interior batches (`WORD_INTERIOR`)
+ // stay excluded (no sun reaches a sealed room); the hook is a no-op when no shadow sun exists.
var world_shadow = 1.0;
- if (lights.n_directional_lights > 0u && (in.word & WORD_INTERIOR) == 0u) {
+ if ((in.word & WORD_INTERIOR) == 0u) {
let view_z = (view.view_from_world * in.world_position).z;
- for (var light_id = 0u; light_id < lights.n_directional_lights; light_id = light_id + 1u) {
- if ((lights.directional_lights[light_id].flags & 1u) != 0u) {
- world_shadow = shadows::fetch_directional_shadow(
- light_id,
- in.world_position,
- n_lit,
- view_z,
- );
- break;
- }
- }
+ let cam_dist = distance(in.world_position.xyz, view.world_position.xyz);
+ world_shadow = shadow_hook::realtime_shadow(
+ in.world_position,
+ n_lit,
+ view_z,
+ cam_dist,
+ wow_light.wmo_fog_params.z,
+ wow_light.fog_params.z,
+ );
}
let shadow_term = mix(SHADOW_SUN_FLOOR, 1.0, world_shadow);
// Keep ambient energy when the realtime map blocks the sun. The retained pass also carries
From 4815126a4f101810807eae734971c1051c83a3ab Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Sat, 5 Sep 2026 22:18:00 +0100
Subject: [PATCH 09/17] MONKEY: torch-shadow point-light lane (spike) +
interior hook
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A point-light shadow lane over the shared rig: the nearest WMO interior fixture
is promoted to a private-layer (31) shadow-casting PointLight proxy, so it casts
the layer-31 world casters into a Bevy cube shadow map; the receiver hook gains
`torch_shadow` (scans clusterable_objects for the shadow-enabled point light and
samples fetch_point_shadow), and static_gx's INTERIOR branch calls it.
Env-gated (WOW_TORCH_SHADOWS), off by default, one torch. FINDING: WMO interiors
are baked-lit (INT batch class = the MOCV bake IS the room's light, including its
baked shadows), so a realtime torch shadow of the STATIC architecture is
redundant and doesn't show via the lit_nl path INT interiors ignore. Committed as
the foundation for dynamic interior lighting (replacing the interior bake with
live torch lighting + realtime shadows) — the next step.
Co-Authored-By: Claude Opus 4.8
---
crates/benilla-app/src/lib.rs | 2 +
crates/benilla-app/src/torch_shadow.rs | 114 ++++++++++++++++++
.../src/shaders/shadow_hook.wgsl | 23 +++-
.../benilla-world/src/shaders/static_gx.wgsl | 9 +-
4 files changed, 144 insertions(+), 4 deletions(-)
create mode 100644 crates/benilla-app/src/torch_shadow.rs
diff --git a/crates/benilla-app/src/lib.rs b/crates/benilla-app/src/lib.rs
index 99cb8217..3af07c60 100644
--- a/crates/benilla-app/src/lib.rs
+++ b/crates/benilla-app/src/lib.rs
@@ -181,6 +181,7 @@ mod ui_unit;
mod ui_world_map;
mod video;
mod vplates;
+mod torch_shadow;
mod world_backdrop;
mod world_shadow;
mod world_state;
@@ -623,6 +624,7 @@ pub fn run(build: BuildId) -> AppExit {
shadow_core::ShadowCorePlugin,
character_shadow::CharacterShadowPlugin,
world_shadow::WorldShadowPlugin,
+ torch_shadow::TorchShadowPlugin,
))
// The realmlist (decision 1667) — the logon address the login screen edits. Same reason as
// VideoPlugin above: it is a CVar knob, so its resource has to exist before `load_config`.
diff --git a/crates/benilla-app/src/torch_shadow.rs b/crates/benilla-app/src/torch_shadow.rs
new file mode 100644
index 00000000..cfdd29bc
--- /dev/null
+++ b/crates/benilla-app/src/torch_shadow.rs
@@ -0,0 +1,114 @@
+//! MONKEY (torch shadows, #2 spike): a point-light shadow lane over the shared rig.
+//!
+//! The nearest WMO interior fixture (a `PointLight` with `LightRooms`) is promoted to a PRIVATE-layer
+//! (layer 31) shadow-casting `PointLight` PROXY at its position. Bevy renders that proxy's cube
+//! shadow map from the layer-31 casters the world lane already builds (walls, columns), so the
+//! interior architecture casts into it for free. The receivers sample it via
+//! `shadow_hook::torch_shadow` (which scans `clusterable_objects` for the shadow-enabled point light
+//! and calls `fetch_point_shadow`). The proxy's own Bevy light is IGNORED by benilla's custom
+//! receivers (they light from the `wow_light` Gouraud term, not Bevy clustering) — only its cube map
+//! + clusterable entry are used.
+//!
+//! SPIKE SCOPE: env-gated (`WOW_TORCH_SHADOWS`), ONE torch, reuses the world lane's layer-31 casters
+//! — so `worldShadows` must be on for the walls/columns to exist as casters. Milestone: a column
+//! throws a shadow on the interior floor that tracks as you move. Not yet a cvar/slider feature.
+
+use bevy::camera::visibility::RenderLayers;
+use bevy::prelude::*;
+
+use benilla_world::lighting::LightRooms;
+use benilla_world::view::WorldCamera;
+
+use crate::char_select::ClientState;
+use crate::shadow_core::{ShadowSet, PLAYER_SHADOW_LAYER};
+
+/// How far from the camera to look for an interior fixture to promote (yd).
+const TORCH_SEARCH_RADIUS: f32 = 60.0;
+/// The proxy light's shadow-cast range (yd) — matches the WMO fixture range (`fx::POINT_LIGHT_RANGE`).
+const TORCH_RANGE: f32 = 48.0;
+
+pub(crate) struct TorchShadowPlugin;
+
+impl Plugin for TorchShadowPlugin {
+ fn build(&self, app: &mut App) {
+ app.init_resource::()
+ .add_systems(Last, update_torch_shadow.in_set(ShadowSet::Lanes));
+ }
+}
+
+/// Marks the proxy point light so it is never itself a promotion candidate.
+#[derive(Component)]
+struct TorchShadowProxy;
+
+#[derive(Resource, Default)]
+struct TorchProxy(Option);
+
+/// `WOW_TORCH_SHADOWS=1` — the spike gate (no cvar yet).
+fn torch_shadows_enabled() -> bool {
+ static ON: std::sync::OnceLock = std::sync::OnceLock::new();
+ *ON.get_or_init(|| std::env::var("WOW_TORCH_SHADOWS").is_ok_and(|v| v != "0"))
+}
+
+fn update_torch_shadow(
+ state: Res>,
+ mut proxy: ResMut,
+ mut commands: Commands,
+ cameras: Query<&GlobalTransform, With>,
+ // Interior WMO fixtures: a `PointLight` that carries a room claim (`LightRooms`). The proxy has
+ // no `LightRooms`, so it is naturally excluded from the candidate set.
+ torches: Query<&GlobalTransform, (With, With)>,
+ mut proxies: Query<&mut Transform, With>,
+) {
+ let active = torch_shadows_enabled() && *state.get() == ClientState::InWorld;
+ if !active {
+ if let Some(entity) = proxy.0.take() {
+ commands.entity(entity).despawn();
+ }
+ return;
+ }
+ let Some(cam) = cameras.iter().next().map(GlobalTransform::translation) else {
+ return;
+ };
+ // The nearest interior fixture within the search radius.
+ let mut nearest: Option<(f32, Vec3)> = None;
+ for fixture in torches.iter() {
+ let pos = fixture.translation();
+ let d2 = pos.distance_squared(cam);
+ if d2 <= TORCH_SEARCH_RADIUS * TORCH_SEARCH_RADIUS
+ && nearest.is_none_or(|(best, _)| d2 < best)
+ {
+ nearest = Some((d2, pos));
+ }
+ }
+ let Some((_, pos)) = nearest else {
+ if let Some(entity) = proxy.0.take() {
+ commands.entity(entity).despawn();
+ }
+ return;
+ };
+ if let Some(entity) = proxy.0 {
+ if let Ok(mut transform) = proxies.get_mut(entity) {
+ transform.translation = pos;
+ }
+ } else {
+ let entity = commands
+ .spawn((
+ PointLight {
+ shadows_enabled: true,
+ range: TORCH_RANGE,
+ // Ignored by the custom receivers; only Bevy's cube-map render + clustering read
+ // the light. A nominal value keeps it from being culled.
+ intensity: 1000.0,
+ shadow_depth_bias: 0.06,
+ shadow_normal_bias: 0.8,
+ ..default()
+ },
+ Transform::from_translation(pos),
+ Visibility::Visible,
+ RenderLayers::layer(PLAYER_SHADOW_LAYER),
+ TorchShadowProxy,
+ ))
+ .id();
+ proxy.0 = Some(entity);
+ }
+}
diff --git a/crates/benilla-assets/src/shaders/shadow_hook.wgsl b/crates/benilla-assets/src/shaders/shadow_hook.wgsl
index 265bb613..dc7327c2 100644
--- a/crates/benilla-assets/src/shaders/shadow_hook.wgsl
+++ b/crates/benilla-assets/src/shaders/shadow_hook.wgsl
@@ -15,7 +15,8 @@
#import bevy_pbr::{
shadows,
- mesh_view_bindings::lights,
+ mesh_view_bindings::{lights, clusterable_objects},
+ mesh_view_types::POINT_LIGHT_FLAGS_SHADOWS_ENABLED_BIT,
}
// The realtime shadow lightens over the last SHADOW_EDGE_BAND yards of the cascade's max distance so
@@ -55,3 +56,23 @@ fn realtime_shadow(
let edge_fade = smoothstep(shadow_range - SHADOW_EDGE_BAND, shadow_range, cam_dist);
return 1.0 - (1.0 - shadow) * night * (1.0 - edge_fade);
}
+
+// MONKEY (torch shadows #2): the realtime POINT-light shadow factor at `sample_pos` — 1.0 = lit,
+// 0.0 = fully shadowed. Scans the view's clusterable objects for shadow-casting point lights (the
+// torch-shadow lane promotes the nearest interior fixture to one, `benilla_app::torch_shadow`) and,
+// for any whose radius covers the fragment, samples its cube shadow map. `min` over them so the
+// darkest occluder wins. A no-op indoors when no such light exists (returns 1.0). Called by the
+// INTERIOR receiver paths (the exterior sun's `realtime_shadow` is gated out of interiors).
+fn torch_shadow(sample_pos: vec4, normal: vec3) -> f32 {
+ var shadow = 1.0;
+ let count = arrayLength(&clusterable_objects.data);
+ for (var i = 0u; i < count; i = i + 1u) {
+ if ((clusterable_objects.data[i].flags & POINT_LIGHT_FLAGS_SHADOWS_ENABLED_BIT) != 0u) {
+ let pr = clusterable_objects.data[i].position_radius;
+ if (distance(pr.xyz, sample_pos.xyz) < pr.w) {
+ shadow = min(shadow, shadows::fetch_point_shadow(i, sample_pos, normal));
+ }
+ }
+ }
+ return shadow;
+}
diff --git a/crates/benilla-world/src/shaders/static_gx.wgsl b/crates/benilla-world/src/shaders/static_gx.wgsl
index 1c993e20..bafa890b 100644
--- a/crates/benilla-world/src/shaders/static_gx.wgsl
+++ b/crates/benilla-world/src/shaders/static_gx.wgsl
@@ -291,9 +291,10 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 {
// The retained path uses the same Bevy directional shadow map as terrain and entity models.
// `static_gx` used to have no mesh-view shadow bindings, which made every Stormwind WMO act
// as if shadows were disabled even though the caster map was populated.
- // MONKEY (shadow hook): the realtime shadow (fetch + edge/night fade, via `benilla::shadow_hook`)
- // — now with the same edge/night fade as terrain + models. Interior batches (`WORD_INTERIOR`)
- // stay excluded (no sun reaches a sealed room); the hook is a no-op when no shadow sun exists.
+ // MONKEY (shadow hook): EXTERIOR surfaces take the directional sun shadow (fetch + edge/night
+ // fade, via `benilla::shadow_hook`) — now with the same fades as terrain + models. INTERIOR
+ // batches (`WORD_INTERIOR`) take the point-light (torch) shadow instead (#2): no sun reaches a
+ // sealed room, but a promoted torch does. Both are no-ops when their light source is absent.
var world_shadow = 1.0;
if ((in.word & WORD_INTERIOR) == 0u) {
let view_z = (view.view_from_world * in.world_position).z;
@@ -306,6 +307,8 @@ fn fragment(in: GxVsOut) -> @location(0) vec4 {
wow_light.wmo_fog_params.z,
wow_light.fog_params.z,
);
+ } else {
+ world_shadow = shadow_hook::torch_shadow(in.world_position, n_lit);
}
let shadow_term = mix(SHADOW_SUN_FLOOR, 1.0, world_shadow);
// Keep ambient energy when the realtime map blocks the sun. The retained pass also carries
From 693d45ac8422db8e00430b32c4f43a6a2d6dd379 Mon Sep 17 00:00:00 2001
From: pkuzic
Date: Mon, 7 Sep 2026 20:12:47 +0100
Subject: [PATCH 10/17] MONKEY: dynamic WMO interiors + cube-map torch shadows
(surfaces + entities)
Interior rooms light per fragment from their authored fixtures instead of
the MOCV bake (direct + fill + ambient, soft rolloff; live cvars
interiorLight/interiorAmbient/interiorFill/interiorExposure/interiorShadows/
interiorDebug), TRANS/EXT batches blend to the day/night reference at the
portal seam, and fixtures admit camera-independently (90 yd).
Torch shadows are a from-scratch depth-map renderer (Bevy point shadows are
dead under ClusterConfig::None): a shared Depth32Float 512^2 x 24-layer Image
+ depth-only pipeline + ViewNode rendering the static caster and a per-frame
entity caster into 6 cube faces for the nearest <=4 fixtures (reverse-Z,
GreaterEqual compare, 4-tap PCF, far fade). WMO surfaces receive through
static_gx group 3; entities receive on the wow_model path through
WowModelExt bindings 91/92/93 (depth array, comparison sampler, 1616-byte
torch table) with a 0.15 yd normal offset against CPU-vs-GPU skinning
self-shadow.
The entity lane is gated on is_interior OR the new MATTE_INDOOR tag bit
(exterior payload bit 14, written by the classifier's Matte law), so a unit
whose verdict flickers Bake<->Matte at one spot keeps its room lighting.
Co-Authored-By: Claude Fable 5.1
---
crates/benilla-app/src/cvars.rs | 51 +-
crates/benilla-app/src/dynamic_interior.rs | 37 ++
crates/benilla-app/src/entities/wmo_props.rs | 6 +-
crates/benilla-app/src/lib.rs | 5 +
crates/benilla-app/src/pipe_warm/menagerie.rs | 11 +-
crates/benilla-app/src/pipe_warm/mod.rs | 6 +
crates/benilla-app/src/torch_shadow.rs | 309 +++++++---
crates/benilla-app/src/video.rs | 21 +
crates/benilla-assets/src/materials.rs | 37 ++
.../src/shaders/shadow_hook.wgsl | 110 +++-
.../benilla-assets/src/shaders/wow_model.wgsl | 151 +++++
crates/benilla-assets/src/world_assets/mod.rs | 15 +-
crates/benilla-world/src/assets/mod.rs | 22 +-
crates/benilla-world/src/interior.rs | 6 +-
.../src/lighting/global_light.rs | 95 ++-
crates/benilla-world/src/lighting/mod.rs | 3 +-
crates/benilla-world/src/mesh_tag.rs | 37 +-
crates/benilla-world/src/model_render.rs | 10 +-
.../benilla-world/src/model_render/batch.rs | 53 +-
crates/benilla-world/src/particles/model.rs | 8 +
crates/benilla-world/src/shaders.rs | 7 +-
.../benilla-world/src/shaders/static_gx.wgsl | 206 ++++++-
.../src/shaders/torch_depth.wgsl | 16 +
crates/benilla-world/src/static_gx/mod.rs | 9 +
crates/benilla-world/src/static_gx/render.rs | 88 +++
.../src/static_gx/torch_depth.rs | 569 ++++++++++++++++++
.../src/terrain_stream/spawn/assemble.rs | 6 +-
.../src/terrain_stream/spawn/mod.rs | 13 +-
28 files changed, 1774 insertions(+), 133 deletions(-)
create mode 100644 crates/benilla-app/src/dynamic_interior.rs
create mode 100644 crates/benilla-world/src/shaders/torch_depth.wgsl
create mode 100644 crates/benilla-world/src/static_gx/torch_depth.rs
diff --git a/crates/benilla-app/src/cvars.rs b/crates/benilla-app/src/cvars.rs
index 076154a8..bf3d08e1 100644
--- a/crates/benilla-app/src/cvars.rs
+++ b/crates/benilla-app/src/cvars.rs
@@ -600,6 +600,41 @@ pub(crate) const REGISTERED: &[Registered] = &[
"1900: benilla's own realtime-shadow render-distance slider; the reference bakes MCSH and \
has no cascade to size",
),
+ // MONKEY (dynamic interiors): WMO interiors + their props light from the room's LIVE fixtures
+ // instead of the MOCV bake / the baked prop probe (`static_gx.wgsl` `interior_room_light`;
+ // bridged by `dynamic_interior`). The three numeric knobs are live-tunable from chat —
+ // `/script SetCVar("interiorExposure", 2)` — which is how their defaults were found.
+ ours(
+ "interiorLight",
+ "1",
+ "benilla's own: fixture-lit WMO interiors (0 = the reference's baked interior path)",
+ ),
+ ours(
+ "interiorAmbient",
+ "0.15",
+ "benilla's own: interior base ambient, 0..1",
+ ),
+ ours(
+ "interiorFill",
+ "0.12",
+ "benilla's own: interior per-fixture bounce gain, 0..2",
+ ),
+ ours(
+ "interiorExposure",
+ "2.5",
+ "benilla's own: interior light-budget multiplier before the soft rolloff, 0.25..8",
+ ),
+ ours(
+ "interiorShadows",
+ "1",
+ "benilla's own: interior fixtures cast real shadows (Stage B, the nearest few); needs \
+ interiorLight",
+ ),
+ ours(
+ "interiorDebug",
+ "0",
+ "benilla's own: interior diagnostic overlay — 1 classification, 2 shadow, 3 caster count",
+ ),
// **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
@@ -1085,6 +1120,14 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
knobs.video.shadow_distance =
v.clamp(*SHADOW_DISTANCE_RANGE.start(), *SHADOW_DISTANCE_RANGE.end());
}
+ // MONKEY (dynamic interiors): the interior lane's on/off + knobs, clamped at the edge like
+ // every other numeric row. `dynamic_interior::bridge` publishes them to benilla-world.
+ "interiorlight" => knobs.video.interior_light = v != 0.0,
+ "interiorambient" => knobs.video.interior_ambient = v.clamp(0.0, 1.0),
+ "interiorfill" => knobs.video.interior_fill = v.clamp(0.0, 2.0),
+ "interiorexposure" => knobs.video.interior_exposure = v.clamp(0.25, 8.0),
+ "interiorshadows" => knobs.video.interior_shadows = v != 0.0,
+ "interiordebug" => knobs.video.interior_debug = (v.max(0.0) as u32).min(3),
// 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.
@@ -1387,7 +1430,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
- let session: [(&str, String); 47] = [
+ let session: [(&str, String); 53] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -1436,6 +1479,12 @@ fn sync_cvars(
("worldShadows", flag(video.world_shadows)),
("characterShadows", flag(video.character_shadows)),
("shadowDistance", video.shadow_distance.to_string()),
+ ("interiorLight", flag(video.interior_light)),
+ ("interiorAmbient", video.interior_ambient.to_string()),
+ ("interiorFill", video.interior_fill.to_string()),
+ ("interiorExposure", video.interior_exposure.to_string()),
+ ("interiorShadows", flag(video.interior_shadows)),
+ ("interiorDebug", video.interior_debug.to_string()),
// The reference's polarity: the CVar is `gxWindow`, so `1` is the WINDOWED state.
(
"gxWindow",
diff --git a/crates/benilla-app/src/dynamic_interior.rs b/crates/benilla-app/src/dynamic_interior.rs
new file mode 100644
index 00000000..8d25440e
--- /dev/null
+++ b/crates/benilla-app/src/dynamic_interior.rs
@@ -0,0 +1,37 @@
+//! MONKEY (dynamic interiors): the app-side half of the fixture-lit WMO interior lane — a
+//! plug-and-play bridge from the `interiorLight` / `interiorAmbient` / `interiorFill` /
+//! `interiorExposure` cvars ([`VideoConfig`]) to benilla-world's [`DynamicInteriors`] resource,
+//! which `global_light` packs for `static_gx.wgsl`'s `interior_room_light` (WMO interior surfaces
+//! AND interior props light from the room's live fixtures instead of the MOCV bake / the baked prop
+//! probe). Independent of the shadow lanes: removing this plugin leaves the resource at its
+//! shipped defaults; `interiorLight 0` restores the faithful baked interior path.
+//!
+//! The knobs are LIVE — `/script SetCVar("interiorExposure", 2)` in chat retunes the room without
+//! a rebuild, which is how the defaults were found.
+use benilla_world::lighting::DynamicInteriors;
+use bevy::prelude::*;
+
+use crate::video::VideoConfig;
+
+pub(crate) struct DynamicInteriorPlugin;
+
+impl Plugin for DynamicInteriorPlugin {
+ fn build(&self, app: &mut App) {
+ app.add_systems(Update, bridge);
+ }
+}
+
+/// Publish the cvars to benilla-world when they change. Guarded so the resource's change ticks
+/// stay quiet on idle frames.
+fn bridge(video: Res, mut out: ResMut