From 97e2d672ee212b0c489a48e52c6d91a3fd71b453 Mon Sep 17 00:00:00 2001 From: pkuzic Date: Sat, 5 Sep 2026 16:44:30 +0100 Subject: [PATCH] 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