MONKEY: shadow_core framework + moving sun + edge/night fade

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 <noreply@anthropic.com>
This commit is contained in:
pkuzic
2026-09-05 16:44:30 +01:00
co-authored by Claude Opus 4.8
parent 3c33fd7117
commit 97e2d672ee
9 changed files with 998 additions and 818 deletions
+1
View File
@@ -22,3 +22,4 @@ trace-*.json
# MONKEY: per-run client state written by benilla (saved variables, camera) — not source.
/benilla-config/
/benilla.bat
+55 -744
View File
@@ -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<bool> = 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<Vec3>, 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<Entity>,
/// 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<Vec3>,
/// The invisible proxy material shared by BOTH lanes' solid casters (opaque to the shadow pass,
/// discarded in the forward pass).
material: Option<Handle<ShadowCasterMaterial>>,
// --- 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<Entity>,
dynamic_mesh: Option<Handle<Mesh>>,
/// [`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<Entity>,
pub(crate) static_mesh: Option<Handle<Mesh>>,
pub(crate) static_rebuilt_at: Option<Vec3>,
/// The alpha-tested foliage casters — one per distinct leaf texture, keyed by its `AssetId`.
pub(crate) static_cutout: HashMap<AssetId<Image>, 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<Self>,
) -> 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<Image>,
}
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<Self>,
) -> 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<Mesh>,
pub(crate) material: Handle<CutoutShadowCasterMaterial>,
}
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::<ShadowCasterMaterial>::default())
// MONKEY (world shadows): the alpha-tested foliage caster material (leaf-shaped canopy shadows).
.add_plugins(MaterialPlugin::<CutoutShadowCasterMaterial>::default())
.init_resource::<ShadowRuntime>()
.add_systems(Last, update_shadows);
app.init_resource::<CharacterLane>()
.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<Entity>,
mesh: Option<Handle<Mesh>>,
/// [`shadow_trace`]'s change detector: (tris, admitted, rejected).
traced: (u32, u32, u32),
}
#[allow(clippy::too_many_arguments)]
fn update_shadows(
state: Res<State<ClientState>>,
fn collect_character_shadows(
video: Res<VideoConfig>,
lighting: Res<WowLighting>,
mut runtime: ResMut<ShadowRuntime>,
mut demand: ResMut<ShadowDemand>,
frame: Res<ShadowFrame>,
mut lane: ResMut<CharacterLane>,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<ShadowCasterMaterial>>,
// MONKEY (world shadows): the per-leaf-texture alpha-tested foliage caster materials.
mut cutout_materials: ResMut<Assets<CutoutShadowCasterMaterial>>,
parts: Query<
(
&PickMesh,
@@ -293,516 +60,60 @@ fn update_shadows(
>,
rigs: Query<&RigSkin>,
palettes: Res<RigPalettes>,
mut cameras: Query<
(
Entity,
Option<&mut RenderLayers>,
Option<&WorldShadowCameraLayer>,
),
With<WorldCamera>,
>,
mut suns: Query<&mut Transform, With<WorldShadowSun>>,
cameras_for_position: Query<&GlobalTransform, With<WorldCamera>>,
// 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<Res<benilla_world::static_gx::StaticGx>>,
// MONKEY (world shadows): the terrain MCSH-suppression flag — driven by the WORLD lane only.
mut world_active: ResMut<WorldShadowActive>,
) {
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::<RenderLayers>();
}
commands.entity(entity).remove::<WorldShadowCameraLayer>();
}
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<Mesh>,
material: Handle<ShadowCasterMaterial>,
) -> 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<u32>) {
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<u32>) {
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<u32>) {
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<WowModelMaterial>>,
),
Without<BillboardCard>,
>,
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, 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]);
}
}
+7 -1
View File
@@ -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)
+648
View File
@@ -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<bool> = 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<Vec3>, 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<Handle<ShadowCasterMaterial>>,
}
/// The rig's own retained state — private to the core.
#[derive(Resource, Default)]
struct ShadowRigState {
sun: Option<Entity>,
/// The direction the sun was LAST GIVEN — the held shadow basis (see [`SUN_SNAP_RADIANS`]).
sun_written: Option<Vec3>,
/// The invisible proxy material shared by every lane's SOLID caster.
material: Option<Handle<ShadowCasterMaterial>>,
}
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<Self>,
) -> 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::<ShadowCasterMaterial>::default())
.init_resource::<ShadowRigState>()
.init_resource::<ShadowDemand>()
.init_resource::<ShadowFrame>()
// 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<State<ClientState>>,
lighting: Res<WowLighting>,
mut demand: ResMut<ShadowDemand>,
mut frame: ResMut<ShadowFrame>,
mut rig: ResMut<ShadowRigState>,
mut commands: Commands,
mut materials: ResMut<Assets<ShadowCasterMaterial>>,
mut cameras: Query<
(Entity, Option<&mut RenderLayers>, Option<&ShadowCameraLayer>),
With<WorldCamera>,
>,
mut suns: Query<&mut Transform, With<ShadowSun>>,
cameras_for_position: Query<&GlobalTransform, With<WorldCamera>>,
) {
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::<RenderLayers>();
}
commands.entity(entity).remove::<ShadowCameraLayer>();
}
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<Mesh>,
material: Handle<ShadowCasterMaterial>,
) -> 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<u32>) {
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<u32>) {
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<u32>) {
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<WowModelMaterial>>,
),
Without<BillboardCard>,
>,
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, 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]);
}
}
+234 -72
View File
@@ -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<Mesh>,
solid_material: &Handle<ShadowCasterMaterial>,
cutout_materials: &mut Assets<CutoutShadowCasterMaterial>,
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::<CutoutShadowCasterMaterial>::default())
.init_resource::<WorldLane>()
.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<Image>,
}
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<Self>,
) -> 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<Mesh>,
material: Handle<CutoutShadowCasterMaterial>,
}
/// 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<Entity>,
static_mesh: Option<Handle<Mesh>>,
static_rebuilt_at: Option<Vec3>,
/// The alpha-tested foliage casters — one per distinct leaf texture.
cutout: bevy::platform::collections::HashMap<AssetId<Image>, CutoutCaster>,
/// The per-frame ENTITY-resident environment caster (gameobjects, faded doodads, WMO props).
env_caster: Option<Entity>,
env_mesh: Option<Handle<Mesh>>,
env_traced: (u32, u32, u32),
}
#[allow(clippy::too_many_arguments)]
fn update_world_shadows(
video: Res<VideoConfig>,
mut demand: ResMut<ShadowDemand>,
frame: Res<ShadowFrame>,
mut lane: ResMut<WorldLane>,
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut cutout_materials: ResMut<Assets<CutoutShadowCasterMaterial>>,
// The retained static world — `Option` because it exists only when the retained pass is armed.
gx: Option<Res<StaticGx>>,
// The terrain MCSH-suppression flag (this lane owns it).
mut world_active: ResMut<WorldShadowActive>,
parts: Query<
(
&PickMesh,
&ModelPart,
Option<&GlobalTransform>,
Option<&RigPart>,
&ShadowOccluder,
Option<&MeshMaterial3d<WowModelMaterial>>,
),
Without<BillboardCard>,
>,
rigs: Query<&RigSkin>,
palettes: Res<RigPalettes>,
) {
// 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<AssetId<Image>> = 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<AssetId<Image>> = runtime
.static_cutout
// Despawn casters whose leaf texture left the reach, so the set stays bounded across zones.
let stale: Vec<AssetId<Image>> = 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<Mesh>,
cutout_materials: &mut Assets<CutoutShadowCasterMaterial>,
) {
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());
}
}
+20 -1
View File
@@ -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<f32> {
}
}
}
// 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<f32> {
// 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);
@@ -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.
@@ -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<WowLighting>,
@@ -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
+7
View File
@@ -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