Files
benilla-octo/crates/benilla-app/src/character_shadow.rs
T
pkuzicandClaude Fable 5.1 9fc7017f34 MONKEY: lighting round 2 — seams, flicker, outdoor torch shadows, perf knobs
Seams
- An exterior-class MOBA batch inside an interior group (the inn's cellar stair) took the
  night sky; its authored day weight is now scaled by the sun, so it joins the room law after
  dark. Exterior-class SHELL groups keep the sky law (moving them to the room law painted
  facades black at dusk).
- Containment claims cross portals larger than 28 yd² (floor splits, gallery voids) at full
  weight, so a floor cut by a group boundary lights continuously. interiorDebug 4 colours WMO
  fragments by lane. wmolights prints group names, lane, portal areas.

Fire
- Flame lights flicker: band-limited sines + smoothed noise per fixture, candle fast/light to
  bonfire slow/deep, seeded by position, mean exactly 1; lamps never; authored fixtures only
  when warm. Cvar fireFlicker.

Outdoor torch shadows (step a)
- At night exterior fire lights become cube-map casters (half the map budget), WMO exteriors
  and models receive them with a world-distance fade; held torches never cast; a fire's own
  model is excluded from its map; daylight is bit-identical. Cvar exteriorShadows.

Performance
- Torch lane: static-scene fingerprint walk gated on a residency/part census (was 4 full
  walks per frame), entity caster rebuilt at interiorShadowEntityRate Hz (30) and gathered
  only within each fixture's reach, receiver shadow taps skipped at zero contribution, table
  and claim uploads and the torch bind group skipped when unchanged. Live bank sized to 8
  dynamic slots (VRAM 192 -> 144 MiB).
- Sun lanes: live cvars shadowMapSize (default 2048, was 4096), shadowFilter,
  characterShadowRate / worldShadowRate (30 Hz proxy rebuilds), shadowCasterReach.
- Measured (inn, 1080p): debug 45 -> 61 FPS, release 83 FPS with everything on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-10 22:50:17 +01:00

144 lines
5.9 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::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>()
.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),
/// 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 commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
parts: Query<
(
&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) {
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;
}
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 (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,
);
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;
}
}
}
}
}