mod commands;
mod flat;
mod graph;
mod ik;
mod morph;
mod root;
pub mod runtime_queue;
#[cfg(test)]
mod tests;
use concinnity_core::animation::anim_graph;
use concinnity_core::animation::pose_blend::PoseBlend;
use concinnity_core::animation::skeleton::AnimationClip;
use concinnity_core::components::Animation;
use concinnity_core::components::AnimationParams;
use concinnity_core::components::CharacterRig;
use concinnity_core::components::GroundProbes;
use concinnity_core::components::RootMotionEvent;
use concinnity_core::components::SkeletonPose;
use concinnity_core::ecs::asset_id::AssetId;
use concinnity_core::ecs::{
Access, FrameTime, MenuActive, PipelineContext, SkinnedMeshHandle, StepResult, System,
};
use concinnity_host::thread::jobs;
use flat::{ClipEntry, FlatState, Transition};
use graph::GraphTarget;
use std::collections::{BTreeMap, HashMap};
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,
clip_secs: f32,
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(),
clip_secs: 0.0,
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
}
}
impl System for AnimationSystem {
fn access(&self) -> Access {
Access::new()
.reads_components(crate::component_mask![CharacterRig])
.writes_components(crate::component_mask![
SkeletonPose,
AnimationParams,
GroundProbes,
])
.reads_resources(crate::resource_mask![MenuActive, FrameTime])
.writes_resources(crate::resource_mask![RootMotionEvent])
}
fn init(&mut self, ctx: &mut PipelineContext) {
let capture_sources = ctx
.resource::<crate::app::run::LaunchRequest>()
.is_some_and(|launch| launch.dev_loop);
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 (id, anim) in ctx.drain_with_ids::<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,
});
if let Some(id) = id {
clip_slots.insert(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 paused = ctx.resource::<MenuActive>().is_some_and(|m| m.0);
if paused {
return StepResult::Continue;
}
let dt = ctx
.resource::<FrameTime>()
.copied()
.unwrap_or_default()
.dt
.max(0.0);
self.clip_secs += dt;
let t = self.clip_secs;
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);
anim_graph::cursor_root_delta(&g.graph, &before, &g.cursor, &g.params, &|i| {
&clips[i].clip
})
}
};
if delta != [0.0; 3] {
ctx.events_mut::<RootMotionEvent>().send(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 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) => 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
}
}