Files
benilla-octo/crates/benilla/src/doodad_anim.rs
T
SamandClaude Fable 5 63efec188f the skinning code catches up, and 59k bone entities become arrays
- An honest correction first: the previous snapshot's message announced the
  owned skinning lane, but the code itself missed that snapshot by minutes —
  two sessions landed at once and the export carried the older tree. The
  skinning lane actually ships here, together with its follow-up.
- The follow-up is the bone-entity collapse. A creature's ~75-bone skeleton
  existed as ~75 world entities, each paying transform propagation and
  bookkeeping every frame, to serve the ~5 bones anything actually hangs off
  (held weapons, glow emitters, the mount seat, overhead markers). A rig's pose
  now lives in plain arrays on the model itself; only the consumer anchor
  points remain as entities. At the crowded dungeon spot ~59,000 entities
  became ~28,000 anchors (80k total → 50k), palette uploads coalesce into a
  handful of writes, and another ~3 CPU ms came off the frame.
- The movement echo the server sends about your own character is applied now,
  the way the original client always applied it — groundwork that GM flight
  cashes in later today.
- The capture harness pins its clock per frame now, not just its frame count —
  a slow run used to drift every animation out from under a golden image.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 21:55:01 +07:00

820 lines
36 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The doodad animation host (decision 0130, phase 1) — world-placed M2 doodads (ADT MDDF + WMO MODD
//! props) animate: flags wave, windmills turn, flame bones jiggle.
//!
//! Byte ground (wow-5875-re `system/animation/scratch/doodad-anim-host.md`, VERIFIED): a doodad's M2
//! instance is armed **once at load** — bone 0, the model's file-order-first sequence
//! (`animations[0].id`), `linkFlag=1` — and the kernel loops it forever via its own modulo-wrap;
//! **global sequences loop with zero arming**, clock-driven; and the kernel ticks as a **draw-time
//! side effect**, so a culled model is simply not evaluated. Because sampling is clock-indexed
//! (`cursor = clock − startOffset`), a re-appearing model shows the pose the shared clock dictates —
//! pausing costs nothing and drifts nothing.
//!
//! benilla's translation: the spawn site ([`crate::terrain_stream`]) classifies each placed model by
//! [`classify`] — the ~90% with no animated channel stay on today's static path (measured,
//! `benilla-extract doodadscan`) — and an animated one spawns the skinned twin + a joint hierarchy
//! under an anim-root entity carrying [`DoodadAnimHost`]: an `AnimationPlayer` looping the
//! first-sequence clip (the one-time arm), and/or a [`GlobalSeqDrive`] for the free-running channels.
//! [`gate_doodad_anim`] here is the draw-time tick made explicit: animation runs iff any of the
//! doodad's submeshes is actually drawn (the `Visibility` verdict the debug-panel authority composes
//! from far-clip + the size-bucketed distance fade + the WMO portal cull), and a resume seeks to
//! `(now − spawned_at) mod duration` — the ref's shared-clock phase, exactly.
use bevy::animation::graph::AnimationNodeIndex;
use bevy::animation::AnimatedBy;
use bevy::app::AnimationSystems;
use bevy::mesh::skinning::SkinnedMeshInverseBindposes;
use bevy::prelude::*;
use benilla_assets::{bone_target_id, AnimClip, M2Model, ModelAnimations, ModelSkeleton};
use crate::creature_anim::GlobalSeqDrive;
/// What a placed doodad model animates — decision 0130's content gate, decided per model at spawn.
pub(crate) enum DoodadAnimTier<'a> {
/// No animated bone channel: today's static path, untouched (no joints, no player — the ~90%
/// measured case: trees, fences, barrels, rocks).
Static,
/// Only free-running global-sequence channels (candelabra glow pulses): joints +
/// [`GlobalSeqDrive`], **no** `AnimationPlayer` — the cheapest animated tier.
GlobalSeqOnly,
/// The **loader-idle seed** moves bones (flags, windmills, torch flames): joints + an
/// `AnimationPlayer` looping this clip — the client's one-time load arm. That seed is animation
/// id 0 (Stand) resolved through the model's own `playableAnimationLookup`, NOT the file-order
/// first sequence (decision 0637; the variant name predates the correction and is kept because
/// it is the tier's identity, not a claim about which sequence). Global-sequence channels ride
/// along if the model also has them.
FirstSeq(&'a AnimClip),
}
/// Classify a placed model for the spawn site. Boneless models are `Static` regardless of parsed
/// tracks — their skinned twin carries joint attributes with no joints to index (the 0035 guard).
pub(crate) fn classify<'a>(
skeleton: &ModelSkeleton,
animations: Option<&'a ModelAnimations>,
) -> DoodadAnimTier<'a> {
let Some(anims) = animations else {
return DoodadAnimTier::Static;
};
if skeleton.joints.is_empty() {
return DoodadAnimTier::Static;
}
match anims.first_seq.and_then(|i| anims.clips.get(i)) {
Some(clip) => DoodadAnimTier::FirstSeq(clip),
None if !anims.global_bones.is_empty() => DoodadAnimTier::GlobalSeqOnly,
None => DoodadAnimTier::Static,
}
}
/// What [`spawn_anim_host`] set up for one animated placement — the spawn site binds each ordinary
/// submesh's skinned twin to `joints`/`inverse_bindposes` and arms the [`DoodadAnimHost`] on `root`
/// with the submesh list once it has spawned them.
pub(crate) struct AnimHostSpawn {
pub root: Entity,
pub joints: Vec<Entity>,
pub inverse_bindposes: Handle<SkinnedMeshInverseBindposes>,
/// The palette rig slot on `root` (decision 0720) — the spawn site tags it into each skinned
/// part's `MeshTag` rig field. `0` = the palette table was full: use the static mesh.
pub slot: u16,
/// The looping first-sequence node + duration, `None` on the gseq-only tier.
pub clip: Option<(AnimationNodeIndex, f32)>,
}
/// Spawn the animation host for one placed M2, if [`classify`] says it animates: an anim-root entity
/// at the placement transform, the joint hierarchy as its children (so despawning the root cascades),
/// and — per tier — an `AnimationPlayer` looping the **loader-idle seed's** clip (the client's
/// one-time load arm at `0x70ebd0`: `0x7121a0(bone 0, animation id 0 resolved through the model's
/// own `playableAnimationLookup`, linkFlag=1)` once — wow-re `gameobject-anim-arm.md` §1, which
/// CORRECTED `doodad-anim-host.md` §1's prose reading of `animations[0].id`; decision 0637) and/or
/// the free-running [`GlobalSeqDrive`]. `None` ⇒ the model is static and
/// the caller keeps today's path untouched.
pub(crate) fn spawn_anim_host(
commands: &mut Commands,
palettes: &mut crate::rig_palette::RigPalettes,
m: &M2Model,
transform: Transform,
) -> Option<AnimHostSpawn> {
// The visual A/B harness (decision 0010) needs deterministic frames; a live animation clock
// isn't. Captures keep every doodad on the static path — bind pose renders identically to the
// static mesh (decision 0035), so world baselines stay comparable across runs and branches.
if crate::capture::scenario_active() {
return None;
}
let tier = classify(&m.skeleton, m.animations.as_ref());
if matches!(tier, DoodadAnimTier::Static) {
return None;
}
let anims = m.animations.as_ref().expect("animated tier ⇒ animations");
let root = commands.spawn((transform, Visibility::default())).id();
let joints = crate::entities::spawn_joints(commands, root, root, &m.skeleton);
// The owned palette rig (decision 0720): the placement's skinned parts tag this slot; the
// palette compute reads the joints' worlds (Bevy's animate_targets still drives them — the
// doodad lane keeps the graph path, 0712's "cold consumers"). Slot 0 (table full, warned) ⇒
// the spawn site falls back to the static mesh.
let slot = match crate::rig_palette::RigSkin::allocate(
palettes,
joints.clone(),
m.inverse_bindposes.clone(),
) {
Some(rig) => {
let slot = rig.slot;
commands.entity(root).insert(rig);
slot
}
None => 0,
};
// Billboard bones on an animated doodad (a swinging lamp's glow child): the palette-level
// camera facing, children inheriting.
if let Some(bb) = crate::billboard::BillboardJointRig::new(&m.skeleton, &joints, root) {
commands.entity(root).insert(bb);
}
let mut clip_info = None;
if let DoodadAnimTier::FirstSeq(head) = tier {
// The effective load arm rolls `variationIdx = −1` — a frequency-weighted pick over the
// first sequence's variation chain (wow-re `doodad-anim-host.md` §4a: the holder setup
// `0x695100` re-arms over the loader's var-0 seed, byte-cited; §5 pair still to close).
// Seeded off the placement position, not a global RNG: deterministic per placement (a
// re-streamed tile re-picks the same variation — the reference re-rolls, invisible either
// way), and instances de-sync exactly like the reference (14 gryphon roosts stop fidgeting
// in lockstep). Single-variation chains (the overwhelming majority) pick the head — today's
// behavior, so the roll costs nothing there.
let t = transform.translation;
let roll = ((t.x.to_bits() ^ t.y.to_bits().rotate_left(11) ^ t.z.to_bits().rotate_left(22))
.wrapping_mul(0x9E37_79B9)
>> 17) as u16
& 0x7fff;
let clip = anims.pick_variation(head.anim_id, roll).unwrap_or(head);
let mut player = AnimationPlayer::default();
player.play(clip.node).repeat();
commands
.entity(root)
.insert((player, AnimationGraphHandle(anims.graph.clone())));
for (i, &j) in joints.iter().enumerate() {
commands
.entity(j)
.insert((bone_target_id(i as u16), AnimatedBy(root)));
}
clip_info = Some((clip.node, clip.duration));
}
if let Some(drive) = GlobalSeqDrive::new(&anims.global_bones, &joints) {
commands.entity(root).insert(drive);
}
Some(AnimHostSpawn {
root,
joints,
inverse_bindposes: m.inverse_bindposes.clone(),
slot,
clip: clip_info,
})
}
/// The anim-root component of one animated doodad placement: the draw gate's state + what to re-arm
/// on resume. The root entity carries the placement transform, the joint hierarchy as children, and
/// (per tier) the `AnimationPlayer`/[`GlobalSeqDrive`]; it lives in the placement's entity list, so
/// it despawns (joints cascading) when the tile streams out.
#[derive(Component)]
pub(crate) struct DoodadAnimHost {
/// The placement's skinned submesh entities — animation runs iff ANY of them is drawn (their
/// `Visibility` is the composed far-clip + distance-fade + portal-cull verdict).
pub meshes: Vec<Entity>,
/// The placement's fade sphere (radius, WORLD center) — the draw-set gate for a MESHLESS
/// host (a particles-only model like the InstancePortal swirl: 0 render batches, so no
/// submesh carries a `Visibility` verdict). Same law the placement's emitters gate on
/// (decision 0171: fade alpha > 0 + frustum sphere), so joints and particle pools
/// freeze/resume in lockstep.
pub fade: (f32, Vec3),
/// The looping first-sequence graph node + its duration (secs); `None` on the gseq-only tier.
pub clip: Option<(AnimationNodeIndex, f32)>,
/// `Time::elapsed_secs` at spawn — the clock origin. A resume seeks the player to
/// `(now − spawned_at) mod duration` and re-seats the [`GlobalSeqDrive`] clock, so phase is a
/// pure function of the shared clock (the ref's arm-time cursor), not of pause history.
pub spawned_at: f32,
/// Whether the host was ticking last frame (edge-triggered pause/resume).
pub active: bool,
}
/// The draw gate: pause a doodad's animation when none of its submeshes is drawn, resume — seeking
/// both clocks to the shared-clock position — when one is again. Runs before [`AnimationSystems`] so
/// a resume's seek lands the same frame. Steady state (nothing flipped) is one `Visibility` read per
/// mesh, no writes.
fn gate_doodad_anim(
time: Res<Time>,
mut hosts: Query<(
&mut DoodadAnimHost,
Option<&mut AnimationPlayer>,
Option<&mut GlobalSeqDrive>,
)>,
vis: Query<&Visibility>,
cam: Query<
(&GlobalTransform, &bevy::camera::primitives::Frustum),
With<crate::player::WorldCamera>,
>,
// The far-clip wall — the meshless host's draw-set gate needs the same depth bound the
// emitters and the doodad meshes use.
view: Res<crate::view::ViewDistance>,
mut logged: Local<bool>,
) {
// One breadcrumb per session, the first frame any host exists — the machine-readable "doodads
// are animating" signal (the live count is in the debug panel).
if !*logged && !hosts.is_empty() {
*logged = true;
info!("doodad anim: first host armed (decision 0130 phase 1)");
}
let now = time.elapsed_secs();
let world_cam = cam.single().ok();
for (mut host, player, drive) in &mut hosts {
let drawn = if host.meshes.is_empty() {
// Meshless (particles-only) host: the emitters' own draw-set law (see the `fade`
// field doc) — the reference ticks animation for any model in the draw set, and a
// 0-batch model is admitted on its fade sphere exactly like its emitters are.
// "Exactly like" is now literal: this calls `EmitterFade::in_draw_set`, the single
// spelling of the rule, rather than a second copy of it. The copy is how the far-clip
// term went missing here as well as in the emitters (decision 0678 / bug B39) — a
// meshless fire prop kept animating its bones at any distance past the wall.
let (radius, center) = host.fade;
world_cam.is_some_and(|(cam_tf, frustum)| {
let cam_pos = cam_tf.translation();
crate::particles::EmitterFade { radius, center }.in_draw_set(
cam_pos,
Vec3::from(cam_tf.forward()),
view.farclip,
frustum.intersects_sphere(
&bevy::camera::primitives::Sphere {
center: center.into(),
radius,
},
false,
),
)
})
} else {
host.meshes
.iter()
.any(|&e| vis.get(e).is_ok_and(|v| *v != Visibility::Hidden))
};
if drawn == host.active {
continue;
}
host.active = drawn;
let t = now - host.spawned_at;
if let Some(mut p) = player {
if drawn {
if let Some((node, duration)) = host.clip {
let anim = p.start(node);
anim.repeat();
if duration > 0.0 {
anim.seek_to(t.rem_euclid(duration));
}
}
} else {
// Stop (not pause): a paused active animation is still sampled and applied every
// frame by `animate_targets`; stopping empties the player so a hidden doodad costs
// nothing. The resume above re-arms + seeks, which is the faithful clock semantics.
p.stop_all();
}
}
if let Some(mut d) = drive {
d.set_paused(!drawn);
if drawn {
d.sync(t);
}
}
}
}
/// Per-submesh **animated material alpha** (decision 0130 phase 2): the batch's baked
/// colour-alpha/weight loops + how this instance clocks them. [`sample_mat_anim`] keeps
/// [`Self::current`] fresh; the model-`Visibility` authority multiplies it into the render-alpha
/// `MeshTag` it already owns (a composed *input*, not an extra tag writer — the 0066 protocol) and
/// hides the batch at combined 0 (the verified `A ≤ 0` cull, wow-re `m2-alpha-combine-cull`).
///
/// The loops are baked **per sequence** (`benilla_formats::AlphaAnim`), so an instance also has to
/// say *which* sequence it is playing — see [`Self::host`].
#[derive(Component)]
pub(crate) struct MatAnim {
anim: std::sync::Arc<benilla_formats::AlphaAnim>,
/// The entity whose `AnimationPlayer` decides which sequence's loops to read, re-resolved every
/// frame — the **unit lane**, where the played sequence changes constantly and the batch's
/// authored visibility changes with it (a voidwalker's upper armour is weight 0 in Stand and 1
/// only in Death). `None` for an instance pinned to one sequence for its life: a placed doodad
/// (armed once at load with `animations[0]`, wow-re `doodad-anim-host.md`) or a spell effect —
/// both then read [`Self::seq`] on the spawn clock, which is the pre-per-sequence behaviour.
host: Option<Entity>,
/// The sequence **file slot** to read: fixed at spawn for the pinned lanes, and the last one
/// resolved from [`Self::host`] for the unit lane. `None` ⇒ slot 0 (the bake's own degrade).
seq: Option<usize>,
/// `Time::elapsed_secs` at spawn — the clock origin (arm-time phase, like the bone host). Only
/// the pinned lanes use it; a hosted instance reads the player's own seek time instead, so its
/// alpha stays in phase with the pose that drives it.
spawned_at: f32,
/// Captures freeze the clock at 0 for deterministic frames (dimming constants still show).
frozen: bool,
/// This instance's sampled value drives the render-alpha `MeshTag` field **by itself** (no
/// `DoodadFade` on the entity): the spell-effect parts (`entities::spell_fx`), whose alpha
/// channel has no other writer. `false` on the doodad lane — there the visibility authority
/// owns the tag and composes [`Self::current`] in (for a fade holder multiplied with the
/// distance fade; for a lit interior prop written alone into the probe payload's alpha field,
/// bits 0..=15 since the 0355 re-lane) — and on the unit lane, whose own compose is
/// [`crate::entities::apply_unit_mat_alpha`].
pub(crate) drives_tag: bool,
/// The last sampled combined factor (colour-alpha × weight), read by the visibility authority.
pub current: f32,
}
impl MatAnim {
pub(crate) fn new(
anim: std::sync::Arc<benilla_formats::AlphaAnim>,
now: f32,
frozen: bool,
) -> Self {
let current = anim.sample(None, 0.0);
Self {
anim,
host: None,
seq: None,
spawned_at: now,
frozen,
drives_tag: false,
current,
}
}
/// The spell-effect-lane constructor: never frozen (the `fxview` instrument ages effects
/// through captures; golden scenarios spawn no effects), the sampled alpha drives the part's
/// render-alpha tag directly (see [`Self::drives_tag`]), and the instance is pinned to the one
/// sequence its rig plays (`seq` — the missile's InFlight, else the file-order-first clip).
pub(crate) fn driving_tag(
anim: std::sync::Arc<benilla_formats::AlphaAnim>,
now: f32,
seq: Option<usize>,
) -> Self {
let mut m = Self::new(anim, now, false);
m.drives_tag = true;
m.seq = seq;
m.current = m.anim.sample(seq, 0.0);
m
}
/// The **unit-lane** constructor: the sequence (and its clock) come from `host`'s live
/// `AnimationPlayer`, so a creature's batches appear and disappear with the animation exactly
/// as authored. The tag is composed by [`crate::entities::apply_unit_mat_alpha`], not driven
/// here — the interior classifier and the appear-fade already own that channel.
pub(crate) fn following(
anim: std::sync::Arc<benilla_formats::AlphaAnim>,
host: Entity,
) -> Self {
Self {
host: Some(host),
..Self::new(anim, 0.0, false)
}
}
/// Whether this instance's tag alpha is the unit lane's to compose (see
/// [`crate::entities::apply_unit_mat_alpha`]).
pub(crate) fn composes_unit_tag(&self) -> bool {
self.host.is_some() && !self.drives_tag
}
}
/// The sequence slot + clip-local time a host is playing, for [`sample_mat_anim`]: the **base**
/// animation with the greatest blend weight. Masked overlays (a torso-only swing, an arm's draw
/// ceremony, the finger grip) run on their own graph nodes and are deliberately skipped — they
/// pose bones, they don't reselect the sequence the material tracks read. During a cross-fade two
/// base clips are live and the heavier one wins; the reference instead blends the two sampled
/// scalars by λ (wow-re `eval.md` FN 0x71af20's blend leg), a sub-blend-time difference on tracks
/// the corpus authors as 0/1 steps — recorded, not modelled.
pub(crate) fn playing_seq(
player: &AnimationPlayer,
anims: &ModelAnimations,
) -> Option<(usize, f32)> {
player
.playing_animations()
.filter_map(|(node, active)| {
let clip = anims.clips.iter().find(|c| c.node == *node)?;
Some((clip.seq_index, active.seek_time(), active.weight()))
})
.max_by(|a, b| a.2.total_cmp(&b.2))
.map(|(seq, t, _)| (seq, t))
}
/// Sample every instance's material-alpha loops: a hosted instance on its host's playing sequence
/// and clip clock, a pinned one on its own spawn clock. Frozen (capture) instances keep their t=0
/// sample. Hidden instances sample too — the visibility authority's alpha cull reads
/// [`MatAnim::current`], so a batch BORN at weight 0 (`HolyLight_Low_Head`'s light column, keyed
/// `0 → 1` at 300 ms) must keep its clock running or the alpha-hide latches forever (the old
/// skip-while-Hidden held `current` at the 0 that caused the hide — the invisible pala-heal flash).
/// Sampling is a pure function of the clock, so an instance hidden for any *other* reason (draw
/// gate, far-clip) still lands right on re-appear, and the reference animation-evaluates the tracks
/// every frame regardless of the cull (wow-re `m2-alpha-combine-cull`). Runs before the visibility
/// authority so the tag it composes is this frame's value.
fn sample_mat_anim(
time: Res<Time>,
hosts: Query<(&AnimationPlayer, &ModelAnimations)>,
mut q: Query<&mut MatAnim>,
) {
let now = time.elapsed_secs();
for mut m in &mut q {
if m.frozen {
continue;
}
// A hosted instance whose host has no player yet (the frame before the rig arms, or a
// rest-pose GameObject) keeps its last resolved slot and reads it at t=0 — the sequence's
// opening pose, which is what a model sitting at bind pose shows.
let played = m
.host
.and_then(|h| hosts.get(h).ok())
.and_then(|(p, a)| playing_seq(p, a));
let (seq, elapsed) = match played {
Some((seq, t)) => {
m.seq = Some(seq);
(Some(seq), t)
}
None if m.host.is_some() => (m.seq, 0.0),
None => (m.seq, now - m.spawned_at),
};
m.current = m.anim.sample(seq, elapsed);
}
}
/// The **UV-animated materials** registry (decision 0130 phase 3, wow-re `m2-texanim-uv`): each
/// batch material carrying a texture-transform translation loop, keyed by material asset id.
/// [`tick_uv_anim_materials`] re-samples every entry's offset into the material's `sun_scale.zw`
/// each frame — one shared uniform per material, so every instance of a model batch scrolls in
/// phase. Exactly faithful for gseq loops (the reference's free-running shared clock); a recorded,
/// invisible divergence for seq-band loops (the reference phases those per instance at arm time —
/// meaningless for a seamless scroll). Entries drop when the material asset does.
#[derive(Resource, Default)]
pub(crate) struct UvAnimMaterials(
pub std::collections::HashMap<
bevy::asset::AssetId<crate::terrain::WowModelMaterial>,
std::sync::Arc<benilla_formats::UvAnim>,
>,
);
/// Scroll every UV-animated material's offset on the shared clock. Skipped entirely in captures
/// (materials keep their t = 0 seed — constants still show, frames stay deterministic). Mutating
/// the material asset re-uploads its packed uniform; the population is tiny (the corpus holds 113
/// texanim models game-wide, a handful in residency), so this is a few uniform writes per frame.
fn tick_uv_anim_materials(
time: Res<Time>,
mut reg: ResMut<UvAnimMaterials>,
mut materials: ResMut<Assets<crate::terrain::WowModelMaterial>>,
) {
if reg.0.is_empty() || crate::capture::scenario_active() {
return;
}
let now = time.elapsed_secs();
reg.0.retain(|id, anim| {
let Some(mat) = materials.get_mut(*id) else {
return false; // material unloaded with its cache — drop the entry
};
let uv = anim.sample(now);
mat.extension.sun_scale.z = uv[0];
mat.extension.sun_scale.w = uv[1];
true
});
}
/// The **tint-animated materials** registry — the M2Color-RGB twin of [`UvAnimMaterials`]: each
/// batch material whose colour track animates (the vertex bake is skipped for those —
/// `benilla-formats` `m2_batches`), keyed by material asset id. [`tick_tint_anim_materials`]
/// re-samples the tint into the material's `tint` uniform each frame on the same shared clock
/// (the same recorded seq-band phase divergence as the UV scroll — invisible for a placed
/// doodad's ambient loop). Spell-effect instances need real per-instance phase instead (one cast
/// = one 0.9 s pulse), so the effect lane clones its materials and ticks them on the instance
/// clock (`entities::spell_fx`), never through this registry.
#[derive(Resource, Default)]
pub(crate) struct TintAnimMaterials(
pub std::collections::HashMap<
bevy::asset::AssetId<crate::terrain::WowModelMaterial>,
std::sync::Arc<benilla_formats::RgbAnim>,
>,
);
/// Re-sample every tint-animated material's RGB on the shared clock (see [`TintAnimMaterials`]).
/// Skipped in captures like the UV scroll (materials keep their first-key seed).
fn tick_tint_anim_materials(
time: Res<Time>,
mut reg: ResMut<TintAnimMaterials>,
mut materials: ResMut<Assets<crate::terrain::WowModelMaterial>>,
) {
if reg.0.is_empty() || crate::capture::scenario_active() {
return;
}
let now = time.elapsed_secs();
reg.0.retain(|id, anim| {
let Some(mat) = materials.get_mut(*id) else {
return false; // material unloaded with its cache — drop the entry
};
let rgb = anim.sample(now);
mat.extension.tint = bevy::math::Vec4::new(rgb[0], rgb[1], rgb[2], 1.0);
true
});
}
pub struct DoodadAnimPlugin;
impl Plugin for DoodadAnimPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<UvAnimMaterials>();
app.init_resource::<TintAnimMaterials>();
app.add_systems(PostUpdate, gate_doodad_anim.before(AnimationSystems));
// Before the visibility authority (`ModelVisSet`): it composes `MatAnim::current` into the
// render-alpha tag the same frame.
app.add_systems(
Update,
(
sample_mat_anim.before(crate::debug_panel::ModelVisSet),
tick_uv_anim_materials,
tick_tint_anim_materials,
),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_assets::{GlobalBone, GlobalSeqChannel};
fn clip(anim_id: u16) -> AnimClip {
AnimClip {
anim_id,
seq_index: 0,
node: AnimationNodeIndex::new(1),
looping: true,
duration: 2.0,
move_speed: 0.0,
blend_time: 0.0,
bounds_center: Vec3::ZERO,
bounds_radius: 0.0,
bounds_min: Vec3::ZERO,
bounds_max: Vec3::ZERO,
events: Vec::new().into(),
arm_nodes: None,
upper_node: None,
frequency: 0,
replay: (0, 0),
}
}
fn anims(clips: Vec<AnimClip>, first_seq: Option<usize>, gseq: bool) -> ModelAnimations {
ModelAnimations {
graph: Handle::default(),
clips,
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
hand_close: [None, None],
global_bones: if gseq {
vec![GlobalBone {
bone: 1,
translation: None,
rotation: None,
scale: Some(GlobalSeqChannel {
period: 1.167,
keys: vec![(0.0, Vec3::ONE), (0.5, Vec3::splat(1.2))],
}),
}]
} else {
Vec::new()
},
first_seq,
pose: Default::default(),
}
}
/// One batch's per-sequence alpha, as the bake emits it: slot 0 hidden, slot 1 visible.
fn two_seq_alpha() -> std::sync::Arc<benilla_formats::AlphaAnim> {
let hidden = benilla_formats::ScalarAnim {
period: 0.0,
step: true,
keys: vec![(0.0, 0.0)],
};
std::sync::Arc::new(
benilla_formats::AlphaAnim::new(vec![
benilla_formats::AlphaSeq {
color: None,
weight: Some(hidden),
},
benilla_formats::AlphaSeq::default(),
])
.expect("a hiding sequence is worth carrying"),
)
}
fn seq_clip(anim_id: u16, seq_index: usize, node: usize) -> AnimClip {
AnimClip {
seq_index,
node: AnimationNodeIndex::new(node),
..clip(anim_id)
}
}
/// The unit lane's resolution: a part reads the sequence its HOST is playing, and follows it
/// when the host changes animation. This is the plumbing B16/B20 turn on — with the old
/// single-sequence bake there was nothing to follow, so a voidwalker's death-only armour drew
/// in every animation.
#[test]
fn hosted_mat_anim_follows_the_host_sequence() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_systems(Update, sample_mat_anim);
// Two clips: graph node 1 is file sequence 0 (which hides the batch), node 2 is slot 1.
let anims = anims(vec![seq_clip(0, 0, 1), seq_clip(1, 1, 2)], Some(0), false);
let mut player = AnimationPlayer::default();
player.play(AnimationNodeIndex::new(1)).repeat();
let host = app.world_mut().spawn((player, anims)).id();
let part = app
.world_mut()
.spawn(MatAnim::following(two_seq_alpha(), host))
.id();
app.update();
assert_eq!(
app.world().entity(part).get::<MatAnim>().unwrap().current,
0.0,
"playing sequence 0 ⇒ the batch is culled"
);
// Switch the host to the other sequence: the same part must now draw.
let world = app.world_mut();
let mut entity = world.entity_mut(host);
{
let mut p = entity.get_mut::<AnimationPlayer>().unwrap();
p.stop_all();
p.play(AnimationNodeIndex::new(2)).repeat();
}
app.update();
assert_eq!(
app.world().entity(part).get::<MatAnim>().unwrap().current,
1.0,
"playing sequence 1 ⇒ the batch draws"
);
}
/// A hosted part whose host has no `AnimationPlayer` yet (the frame before the rig arms, a
/// rest-pose GameObject) still resolves — to slot 0 at t=0, the sequence's opening pose. It
/// must not read as fully visible by default, or the batch flashes for a frame at spawn.
#[test]
fn hosted_mat_anim_without_a_player_reads_the_first_sequence() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_systems(Update, sample_mat_anim);
let host = app.world_mut().spawn(Transform::default()).id();
let part = app
.world_mut()
.spawn(MatAnim::following(two_seq_alpha(), host))
.id();
app.update();
assert_eq!(
app.world().entity(part).get::<MatAnim>().unwrap().current,
0.0
);
}
/// The pinned lanes are unchanged: a doodad/effect instance reads the slot it was built with,
/// on its own spawn clock, and never consults a host.
#[test]
fn pinned_mat_anim_reads_its_own_slot() {
let a = two_seq_alpha();
let doodad = MatAnim::new(a.clone(), 0.0, false);
assert_eq!(doodad.current, 0.0, "slot 0 — the one-time load arm");
assert!(!doodad.composes_unit_tag());
let effect = MatAnim::driving_tag(a, 0.0, Some(1));
assert_eq!(effect.current, 1.0, "the sequence the fx rig armed");
assert!(effect.drives_tag);
assert!(!effect.composes_unit_tag());
}
fn skeleton(joints: usize) -> ModelSkeleton {
ModelSkeleton {
joints: (0..joints)
.map(|_| benilla_assets::ModelJoint {
parent: -1,
local_translation: Vec3::ZERO,
billboard: None,
ignore_parent_rotation: false,
})
.collect(),
spine_bone: None,
head_bone: None,
}
}
/// The content gate: no animations / no joints / a motionless first sequence ⇒ static (today's
/// path); gseq channels alone ⇒ the player-less tier; a moving first sequence ⇒ the looping arm.
#[test]
fn classify_picks_the_measured_tiers() {
// No ModelAnimations at all (a barrel): static.
assert!(matches!(
classify(&skeleton(3), None),
DoodadAnimTier::Static
));
// Boneless model: static even with parsed animations (the 0035 out-of-bounds guard).
let a = anims(vec![clip(0)], Some(0), true);
assert!(matches!(
classify(&skeleton(0), Some(&a)),
DoodadAnimTier::Static
));
// A motionless first sequence, no gseq (a posed tree): static — no skin, no player.
let a = anims(vec![clip(0)], None, false);
assert!(matches!(
classify(&skeleton(3), Some(&a)),
DoodadAnimTier::Static
));
// Gseq only (a candelabra glow pulse): the player-less tier.
let a = anims(Vec::new(), None, true);
assert!(matches!(
classify(&skeleton(3), Some(&a)),
DoodadAnimTier::GlobalSeqOnly
));
// A moving first sequence (a flag / the torch flame bone): loop its clip.
let a = anims(vec![clip(0)], Some(0), true);
assert!(matches!(
classify(&skeleton(3), Some(&a)),
DoodadAnimTier::FirstSeq(c) if c.anim_id == 0
));
}
/// The draw gate stops the player + pauses the gseq drive when every submesh is hidden, and on
/// re-appearing re-arms at the shared-clock position (`(now − spawned_at) mod duration`) — the
/// ref's clock-indexed pose, so pause history never desyncs phase.
#[test]
fn gate_pauses_hidden_and_resumes_on_the_shared_clock() {
let mut app = App::new();
app.add_plugins(MinimalPlugins); // Time
// The gate reads the far-clip wall (0678) — this host is mesh-BACKED, so it takes the
// `Visibility` branch and never consults it, but the system still needs the resource.
app.init_resource::<crate::view::ViewDistance>();
app.add_systems(Update, gate_doodad_anim);
let mesh = app.world_mut().spawn(Visibility::Inherited).id();
let node = AnimationNodeIndex::new(1);
let mut player = AnimationPlayer::default();
player.play(node).repeat();
let joint = app.world_mut().spawn(Transform::default()).id();
let drive = GlobalSeqDrive::new(
&anims(Vec::new(), None, true).global_bones,
&[Entity::PLACEHOLDER, joint],
)
.expect("one gseq bone maps");
let host = app
.world_mut()
.spawn((
DoodadAnimHost {
meshes: vec![mesh],
fade: (1.0, Vec3::ZERO),
clip: Some((node, 2.0)),
spawned_at: 0.0,
active: true,
},
player,
drive,
))
.id();
// Visible: stays active, player untouched.
app.update();
let playing = |app: &mut App, e: Entity| {
app.world_mut()
.entity(e)
.get::<AnimationPlayer>()
.unwrap()
.playing_animations()
.count()
};
assert_eq!(playing(&mut app, host), 1, "drawn ⇒ playing");
// Hide the one submesh: the player empties (stopped, zero per-frame cost).
*app.world_mut()
.entity_mut(mesh)
.get_mut::<Visibility>()
.unwrap() = Visibility::Hidden;
app.update();
assert_eq!(playing(&mut app, host), 0, "hidden ⇒ stopped");
assert!(
!app.world()
.entity(host)
.get::<DoodadAnimHost>()
.unwrap()
.active
);
// Show it again: re-armed, looping, sought to the shared-clock position (< duration).
*app.world_mut()
.entity_mut(mesh)
.get_mut::<Visibility>()
.unwrap() = Visibility::Inherited;
app.update();
assert_eq!(playing(&mut app, host), 1, "re-drawn ⇒ re-armed");
let world = app.world();
let p = world.entity(host).get::<AnimationPlayer>().unwrap();
let active = p.animation(node).expect("the first-seq node is active");
assert!(
active.seek_time() >= 0.0 && active.seek_time() < 2.0,
"seek lands inside the loop: {}",
active.seek_time()
);
}
}