mod commands;
mod flat;
mod graph;
mod ik;
mod morph;
mod root;
#[cfg(test)]
mod tests;
use std::collections::{BTreeMap, HashMap};
use std::time::Instant;
use crate::components::{Animation, SkeletonPose};
use crate::ecs::asset_id::AssetId;
use crate::ecs::{PipelineContext, SkinnedMeshHandle, StepResult, System};
use crate::gfx::pose_blend::PoseBlend;
use crate::gfx::skeleton::AnimationClip;
use crate::jobs;
use flat::{ClipEntry, FlatState, Transition};
use graph::GraphTarget;
struct TargetState {
clips: Vec<ClipEntry>,
mode: TargetMode,
}
enum TargetMode {
Flat(FlatState),
Graph(GraphTarget),
}
#[derive(Debug, Clone)]
pub struct AnimationReloadEntry {
pub target: SkinnedMeshHandle,
pub clip_index: usize,
pub source: String,
pub skin_index: u32,
pub animation_index: u32,
pub animation_name: String,
pub sample_rate: f32,
pub weight: f32,
pub looping: bool,
}
pub struct AnimationSystem {
targets: BTreeMap<SkinnedMeshHandle, TargetState>,
name_index: crate::gfx::skinned_mesh_map::SkinnedMeshNameIndex,
start: Option<Instant>,
last_step_secs: Option<f32>,
pause_anchor: Option<Instant>,
reload_entries: Vec<AnimationReloadEntry>,
ik_frames: std::collections::HashMap<SkinnedMeshHandle, ik::IkFrame>,
ik_feet_scratch: Vec<[f32; 3]>,
}
impl std::fmt::Debug for AnimationSystem {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AnimationSystem")
.field("targets", &self.targets.len())
.field("reload_entries", &self.reload_entries.len())
.finish()
}
}
impl Default for AnimationSystem {
fn default() -> Self {
Self::new()
}
}
impl AnimationSystem {
pub fn new() -> Self {
Self {
targets: BTreeMap::new(),
name_index: Default::default(),
start: None,
last_step_secs: None,
pause_anchor: None,
reload_entries: Vec::new(),
ik_frames: std::collections::HashMap::new(),
ik_feet_scratch: Vec::new(),
}
}
pub fn reload_entries(&self) -> &[AnimationReloadEntry] {
&self.reload_entries
}
pub fn apply_reloaded_clip(
&mut self,
target: SkinnedMeshHandle,
clip_index: usize,
clip: AnimationClip,
weight: f32,
) -> bool {
let Some(bucket) = self.targets.get_mut(&target) else {
return false;
};
let Some(slot) = bucket.clips.get_mut(clip_index) else {
return false;
};
slot.clip = clip;
slot.declared_weight = weight;
if let TargetMode::Graph(g) = &mut bucket.mode {
let duration = bucket.clips[clip_index].clip.duration;
g.graph.refresh_clip_duration(clip_index, duration);
}
true
}
}
fn resumed_origin(start: Instant, anchor: Instant, now: Instant) -> Instant {
start + now.saturating_duration_since(anchor)
}
impl System for AnimationSystem {
fn access(&self) -> crate::ecs::Access {
crate::ecs::Access::new()
.reads_components(crate::component_mask![crate::components::CharacterRig])
.writes_components(crate::component_mask![
crate::components::SkeletonPose,
crate::components::AnimationParams,
crate::components::GroundProbes,
])
.reads_resources(crate::resource_mask![crate::ecs::MenuActive])
.writes_resources(crate::resource_mask![crate::components::RootMotionEvent])
}
fn init(&mut self, ctx: &mut PipelineContext) {
let capture_sources = crate::app::dev_flags::enabled();
self.name_index = ctx
.resource::<crate::gfx::skinned_mesh_map::SkinnedMeshNameIndex>()
.cloned()
.unwrap_or_default();
let skin_index = ctx
.resource::<crate::gfx::skinned_mesh_map::SkinnedMeshSkinIndex>()
.cloned()
.unwrap_or_default();
let mut clip_slots: HashMap<AssetId, (SkinnedMeshHandle, usize)> = HashMap::new();
let mut count = 0usize;
for anim in ctx.drain::<Animation>() {
let Some(target) = anim.target else {
tracing::warn!("AnimationSystem: Animation has no target SkinnedMesh, ignored");
continue;
};
let weight = anim.weight;
let fade_in_secs = anim.fade_in_secs.max(0.0);
let state = self.targets.entry(target).or_insert_with(|| TargetState {
clips: Vec::new(),
mode: TargetMode::Flat(FlatState::default()),
});
let clip_index = state.clips.len();
state.clips.push(ClipEntry {
clip: anim.to_clip(),
declared_weight: weight,
fade_in_secs,
});
clip_slots.insert(anim.asset_id, (target, clip_index));
let initial = if fade_in_secs > 0.0 { 0.0 } else { weight };
if let TargetMode::Flat(flat) = &mut state.mode {
flat.current_weights.push(initial);
}
if capture_sources && !anim.source.is_empty() {
self.reload_entries.push(AnimationReloadEntry {
target,
clip_index,
source: anim.source.clone(),
skin_index: skin_index.get(target),
animation_index: anim.animation_index,
animation_name: anim.animation_name.clone(),
sample_rate: anim.sample_rate,
weight,
looping: anim.looping,
});
}
count += 1;
}
let graph_count = graph::install_graphs(&mut self.targets, ctx, &clip_slots);
for state in self.targets.values_mut() {
let TargetMode::Flat(flat) = &mut state.mode else {
continue;
};
let max_fade = state
.clips
.iter()
.fold(0.0f32, |m, c| m.max(c.fade_in_secs));
if max_fade > 0.0 {
let source = flat.current_weights.clone();
let target: Vec<f32> = state.clips.iter().map(|c| c.declared_weight).collect();
flat.transition = Some(Transition {
source_weights: source,
target_weights: target,
start_secs: 0.0,
duration_secs: max_fade,
});
}
}
tracing::info!(
"AnimationSystem: {} clip(s) across {} target mesh(es); {} graph(s); {} \
file-backed clip(s) captured for hot-reload",
count,
self.targets.len(),
graph_count,
self.reload_entries.len()
);
}
fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
let now = Instant::now();
let paused = ctx
.resource::<crate::ecs::MenuActive>()
.is_some_and(|m| m.0);
if paused {
self.pause_anchor.get_or_insert(now);
return StepResult::Continue;
}
if let Some(anchor) = self.pause_anchor.take()
&& let Some(start) = self.start.as_mut()
{
*start = resumed_origin(*start, anchor, now);
}
let start = *self.start.get_or_insert(now);
let t = (now - start).as_secs_f32();
let dt = t - self.last_step_secs.replace(t).unwrap_or(t);
for state in self.targets.values_mut() {
if let TargetMode::Flat(flat) = &mut state.mode
&& let Some(tr) = flat.transition.as_mut()
&& tr.start_secs == 0.0
{
tr.start_secs = t;
}
}
for (target, state) in &mut self.targets {
let TargetState { clips, mode } = state;
let delta = match mode {
TargetMode::Flat(flat) => {
flat::advance_weights(flat, t);
root::flat_root_delta(clips, &flat.current_weights, t - dt, t)
}
TargetMode::Graph(g) => {
let before = g.cursor.clone();
graph::step_target(g, *target, ctx, dt);
crate::gfx::anim_graph::cursor_root_delta(
&g.graph,
&before,
&g.cursor,
&g.params,
&|i| &clips[i].clip,
)
}
};
if delta != [0.0; 3] {
ctx.events_mut::<crate::components::RootMotionEvent>().send(
crate::components::RootMotionEvent {
target: *target,
delta,
},
);
}
}
ik::frame_inputs(&self.targets, ctx, &mut self.ik_frames);
let ik_frames = &self.ik_frames;
let targets = &self.targets;
let poses = ctx.query_slice_mut::<SkeletonPose>();
jobs::pool().parallel_for(poses, |pose| {
let Some(state) = targets.get(&pose.mesh_id) else {
return;
};
let crate::components::SkeletonPose {
skeleton,
scratch,
joint_matrices,
morph_weights,
morph_base,
proportions,
updated,
..
} = pose;
match &state.mode {
TargetMode::Flat(flat) => match state.clips.as_slice() {
[] => return,
[single] => {
single.clip.sample_into(t, skeleton, &mut scratch.locals)
}
many => {
let mut fold = PoseBlend::new(&mut scratch.locals);
for (i, entry) in many.iter().enumerate() {
let w = flat.current_weights.get(i).copied().unwrap_or(1.0);
if fold.seeded() && w <= 0.0 {
continue;
}
entry.clip.sample_into(t, skeleton, &mut scratch.clip);
fold.add(&scratch.clip, w);
}
}
},
TargetMode::Graph(g) => crate::gfx::anim_graph::sample_graph_pose_into(
&g.graph,
&g.cursor,
&g.params,
|i| &state.clips[i].clip,
skeleton,
scratch,
),
}
if let TargetMode::Graph(g) = &state.mode
&& let Some(frame) = ik_frames.get(&pose.mesh_id)
{
ik::apply_chains(skeleton, scratch, &g.chains, frame);
}
proportions.apply(&mut scratch.locals);
skeleton.skinning_matrices_into(&scratch.locals, joint_matrices);
*updated = true;
if let TargetMode::Flat(flat) = &state.mode {
morph::update_weights(&state.clips, flat, t, morph_base, scratch, morph_weights);
}
});
ik::refresh_rays(&self.targets, ctx, &mut self.ik_feet_scratch);
StepResult::Continue
}
}