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:
co-authored by
Claude Opus 4.8
parent
3c33fd7117
commit
97e2d672ee
@@ -22,3 +22,4 @@ trace-*.json
|
||||
|
||||
# MONKEY: per-run client state written by benilla (saved variables, camera) — not source.
|
||||
/benilla-config/
|
||||
/benilla.bat
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user