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