- Advanced Graphics page: Lighting Quality preset (Off/Low/Medium/High/Custom, one const table, Off = the original client's look), shadow rows moved from Graphics, Shadow Resolution, Moon Shadows, interior/torch/fire/spell/night rows; EN+RU strings; works in player builds. - Moon shadows: the single shadow rig re-aims at the moon at night (MoonShadowStrength 0.35); attenuates only the night sky term, never point lights; covers terrain, WMO exteriors, models and retained doodads; stateful hand-over for clock jumps; blob gated on a built silhouette; zero cost at strength 0. Signed fog_params.z carries +sun / -moon*strength. - Area spell lights: SpellLightMode::Area for Flamestrike, Rain of Fire, Consecration, Flare, Explosive Trap (school-based where the model says nothing); Blizzard/Hurricane/Volley/frost traps stay dark; repeating impacts deduped; benilla-extract spellvis prints the area block. 1121 world+formats tests; 8/8 WGSL probes; UI and world smoke scenes clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
170 lines
7.0 KiB
Rust
170 lines
7.0 KiB
Rust
//! The CHARACTER shadow lane — a thin plug-in on top of [`super::shadow_core`].
|
|
//!
|
|
//! 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::pbr::MeshMaterial3d;
|
|
use bevy::ecs::entity::EntityHashSet;
|
|
use bevy::prelude::*;
|
|
|
|
use benilla_assets::materials::WowModelMaterial;
|
|
use benilla_world::billboard::BillboardCard;
|
|
use benilla_world::interact::PickMesh;
|
|
use benilla_world::model_render::{ModelPart, ShadowOccluder};
|
|
use benilla_world::rig_palette::{RigPalettes, RigPart, RigSkin};
|
|
|
|
use crate::shadow_core::{
|
|
collect_entity_geometry, empty_shadow_mesh, restore_mesh_buffers, shadow_trace,
|
|
spawn_solid_caster, take_mesh_buffers, RebuildRate, ShadowDemand, ShadowFrame, ShadowSet,
|
|
};
|
|
use crate::video::VideoConfig;
|
|
|
|
pub(crate) struct CharacterShadowPlugin;
|
|
|
|
impl Plugin for CharacterShadowPlugin {
|
|
fn build(&self, app: &mut App) {
|
|
app.init_resource::<CharacterLane>()
|
|
.init_resource::<CharacterShadowReady>()
|
|
.add_systems(Last, collect_character_shadows.in_set(ShadowSet::Lanes));
|
|
}
|
|
}
|
|
|
|
/// MONKEY (moon shadows): roots/ancestors with triangles in the LAST completed caster build.
|
|
/// Blobs consume this in PostUpdate, before this frame's Last rebuild: a newly uploaded proxy
|
|
/// gets a render frame before its oval yields. Cadence skips retain the same geometry/verdict.
|
|
#[derive(Resource, Default)]
|
|
pub(crate) struct CharacterShadowReady(pub EntityHashSet);
|
|
|
|
/// 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),
|
|
/// MONKEY (sun shadow perf): the `characterShadowRate` cadence gate — see the rebuild comment
|
|
/// in [`collect_character_shadows`].
|
|
rate: RebuildRate,
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
fn collect_character_shadows(
|
|
video: Res<VideoConfig>,
|
|
time: Res<Time>,
|
|
mut demand: ResMut<ShadowDemand>,
|
|
frame: Res<ShadowFrame>,
|
|
mut lane: ResMut<CharacterLane>,
|
|
mut ready: ResMut<CharacterShadowReady>,
|
|
parents: Query<&ChildOf>,
|
|
mut commands: Commands,
|
|
mut meshes: ResMut<Assets<Mesh>>,
|
|
parts: Query<
|
|
(
|
|
Entity,
|
|
&PickMesh,
|
|
&ModelPart,
|
|
Option<&GlobalTransform>,
|
|
Option<&RigPart>,
|
|
&ShadowOccluder,
|
|
Option<&MeshMaterial3d<WowModelMaterial>>,
|
|
),
|
|
Without<BillboardCard>,
|
|
>,
|
|
rigs: Query<&RigSkin>,
|
|
palettes: Res<RigPalettes>,
|
|
) {
|
|
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;
|
|
|
|
// Rig down or lane off → drop our caster so it stops casting.
|
|
if !(frame.active && on) {
|
|
ready.0.clear();
|
|
if let Some(entity) = lane.caster.take() {
|
|
commands.entity(entity).despawn();
|
|
}
|
|
if let Some(handle) = lane.mesh.take() {
|
|
meshes.remove(handle.id());
|
|
}
|
|
// The mesh this gate was pacing is gone — re-arm so a re-enable builds on its first frame.
|
|
lane.rate.reset();
|
|
return;
|
|
}
|
|
// MONKEY (moon shadows): keep the cache but suspend CPU skin/re-upload on feature-off nights.
|
|
if frame.suspended {
|
|
ready.0.clear();
|
|
lane.rate.reset();
|
|
return;
|
|
}
|
|
let Some(material) = frame.material.clone() else {
|
|
return;
|
|
};
|
|
|
|
// Animated rigs re-skin every frame and creatures spawn/despawn under lifecycles this lane
|
|
// can't cheaply observe — so the caster is collected fresh, at a CAPPED cadence.
|
|
if lane.caster.is_none() {
|
|
let mesh = meshes.add(empty_shadow_mesh());
|
|
let entity = spawn_solid_caster(&mut commands, mesh.clone(), material);
|
|
lane.caster = Some(entity);
|
|
lane.mesh = Some(mesh);
|
|
// A brand-new caster is an EMPTY mesh; make sure this frame fills it.
|
|
lane.rate.reset();
|
|
}
|
|
// MONKEY (sun shadow perf): the `characterShadowRate` gate. This rebuild is the expensive half
|
|
// of the ~3 ms the character lane costs — it CPU-skins every admitted unit and then mutates the
|
|
// `Mesh` asset, and a mutated mesh is re-extracted and re-uploaded (vertices AND indices) to the
|
|
// GPU. Skipping it simply leaves the previous proxy in place: the shadow MAP is still rendered
|
|
// from that proxy every frame, so nothing flickers or disappears — a moving unit's silhouette
|
|
// merely lags by up to 1/rate second.
|
|
//
|
|
// Why a rate cap and not change detection: the obvious `Changed<GlobalTransform>` gate does not
|
|
// work here. A unit's shadow geometry comes from the RIG PALETTE (`append_skinned` reads
|
|
// `RigPalettes`, the per-frame skinning pose), not from the part's transform — so a standing NPC
|
|
// whose idle animation is breathing has an unchanged `GlobalTransform` and changed VERTICES.
|
|
// Gating on the transform would freeze exactly the poses this lane exists to draw. The rate cap
|
|
// is the honest saving, and it is bounded work rather than a guess about content.
|
|
if !lane.rate.due(time.elapsed_secs(), video.character_shadow_rate) {
|
|
return;
|
|
}
|
|
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 mut built_parts = EntityHashSet::default();
|
|
let (admitted, rejected) = collect_entity_geometry(
|
|
&parts,
|
|
&rigs,
|
|
&palettes,
|
|
true, // want_creatures
|
|
false, // want_environment (the world lane's job)
|
|
frame.light_position,
|
|
frame.entity_reach,
|
|
frame.tall_reach,
|
|
&mut positions,
|
|
&mut indices,
|
|
Some(&mut built_parts),
|
|
);
|
|
ready.0.clear();
|
|
for part in built_parts {
|
|
ready.0.insert(part);
|
|
// The unit root owns its blob; mounts and equipment can be nested below it.
|
|
ready.0.extend(parents.iter_ancestors(part));
|
|
}
|
|
let tris = (indices.len() / 3) as u32;
|
|
restore_mesh_buffers(mesh, positions, indices);
|
|
if shadow_trace() {
|
|
let now = (tris, admitted, rejected);
|
|
if lane.traced != now {
|
|
info!(
|
|
"shadow-trace: character {} tris | creatures admitted {} / rejected {} (was {:?})",
|
|
tris, admitted, rejected, lane.traced
|
|
);
|
|
lane.traced = now;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|