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