`scripts/check.sh` has been failing before it ran a single test, on 62 clippy findings in code none of the recent rounds touched — 32 in `benilla-world`, 30 in `benilla-app` — so the in-loop gate could not tell anyone anything. Every one is a newer clippy noticing older code (`as_chunks` and `is_multiple_of` are both lints younger than the lines they fire on), and they are fixed, not silenced, except where the silence is the point: - **Argued allows, the house idiom.** `clippy::type_complexity` on ten Bevy system signatures and two inline caster queries, each with the one-line reason beside it, as the other 67 in this workspace have. `clippy::approx_constant` on `liquid/waves.rs`'s `TWO_PI`, whose own doc already says why it is spelled `6.2831853`: it mirrors a shader line, and `f32::consts::TAU` would stop reading against the file it copies. - **Two runtime assertions became compile-time ones** (`EXT_SEL_EMPTY is index 255`, the endless-stream frame count): a claim about constants should fail the build that breaks it, not a test run after it. - **Three real, if harmless, redundancies gone**: `len > MAX_TILT_TAN && len > 0.0` (the constant is `tan(4°)`, so the second test never spoke), `u32::from(..) + 1 | flags` now parenthesised where `+` binds tighter than `|`, and `palette.get(0)`. - The rest is mechanical: `chunks_exact(3)` → `as_chunks::<3>()`, `map_or(true, ..)` → `is_none_or`, two `Default::default()`-then-assign test fixtures, a doc line that began `1.` and so turned its own paragraph into a list item, and `local_utc_offset_secs` moved above the test module it had drifted below. `tests/repro_mail.rs` is deleted. Its first line says "TEMPORARY repro harness — not for commit"; it reads a loose mirror of FrameXML out of `/tmp/opencode/ vanilla-ui`, which no longer exists, so it has been failing on a missing directory rather than telling anyone about mail. What is left of the gate's red is one test: `world_api_wall` counts 174 crossings of the `benilla-world` boundary against a ceiling of 107 — 90 of them already filed with a close plan, 84 unsorted. That is not cleanup; the wall's own message says the remedies are to close the crossing or to publish it as engine API with the record that says so, and 84 of those is a design pass on what the engine's public surface is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
174 lines
7.1 KiB
Rust
174 lines
7.1 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::ecs::entity::EntityHashSet;
|
|
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>()
|
|
.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)]
|
|
#[allow(clippy::type_complexity)] // one Bevy system's full input set
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|