use std::collections::{BTreeMap, HashMap};
use crate::components::{AnimationGraph, AnimationParams};
use crate::ecs::asset_id::AssetId;
use crate::ecs::{PipelineContext, SkinnedMeshHandle};
use crate::gfx::anim_graph::{CompiledGraph, GraphCursor};
use super::{TargetMode, TargetState};
pub(super) struct GraphTarget {
pub graph: CompiledGraph,
pub cursor: GraphCursor,
pub params: Vec<f32>,
pub pending: Vec<(usize, f32)>,
pub chains: Vec<super::ik::IkChainRuntime>,
}
pub(super) fn install_graphs(
targets: &mut BTreeMap<SkinnedMeshHandle, TargetState>,
ctx: &mut PipelineContext,
clip_slots: &HashMap<AssetId, (SkinnedMeshHandle, usize)>,
) -> usize {
let mut installed = 0usize;
for g in ctx.drain::<AnimationGraph>() {
let Some(target) = g.target else {
tracing::warn!(
"AnimationSystem: AnimationGraph {} has no target, ignored",
g.asset_id
);
continue;
};
let Some(bucket) = targets.get_mut(&target) else {
tracing::warn!(
"AnimationSystem: AnimationGraph {} targets handle {} which has no clips, ignored",
g.asset_id,
target.index()
);
continue;
};
let compiled = g.compile(|anim_id| {
let &(slot_target, index) = clip_slots.get(&anim_id)?;
if slot_target != target {
return None;
}
let clip = &bucket.clips[index].clip;
Some((index, clip.duration, clip.looping))
});
match compiled {
Ok(graph) => {
let params = graph.default_params();
ctx.push(AnimationParams::new(target, params.clone()));
let chains = if g.ik_chains.is_empty() {
Vec::new()
} else if let Some(skeleton) = ctx
.query::<crate::components::SkeletonPose>()
.find(|p| p.mesh_id == target)
.map(|p| p.skeleton.clone())
{
super::ik::resolve_chains(g.asset_id, &g.ik_chains, &g.parameters, &skeleton)
} else {
tracing::warn!(
"AnimationSystem: AnimationGraph {} has ik_chains but target handle {} has \
no skeleton pose; IK disabled",
g.asset_id,
target.index()
);
Vec::new()
};
if !chains.is_empty() {
ctx.push(crate::components::GroundProbes {
target,
probes: Vec::new(),
});
}
bucket.mode = TargetMode::Graph(GraphTarget {
cursor: GraphCursor::start(&graph),
graph,
params,
pending: Vec::new(),
chains,
});
installed += 1;
}
Err(e) => tracing::warn!("AnimationSystem: {e}; falling back to weighted blend"),
}
}
installed
}
pub(super) fn step_target(
g: &mut GraphTarget,
target: SkinnedMeshHandle,
ctx: &mut PipelineContext,
dt_secs: f32,
) {
if let Some(params) = ctx
.query_mut::<AnimationParams>()
.find(|p| p.target == target)
{
for (index, value) in g.pending.drain(..) {
params.set(index, value);
}
g.params.clone_from(¶ms.values);
} else {
for (index, value) in g.pending.drain(..) {
if let Some(slot) = g.params.get_mut(index) {
*slot = value;
}
}
}
g.cursor.advance(&g.graph, &g.params, dt_secs);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::blob::BlobData;
use crate::ecs::asset_id::intern;
use crate::ecs::{ComponentSlot, ComponentStorage, Resources};
use crate::gfx::profile::FrameProfile;
use super::super::TargetState;
use super::super::flat::{ClipEntry, FlatState};
fn clip_entry() -> ClipEntry {
ClipEntry {
clip: crate::gfx::skeleton::AnimationClip {
morph_keys: Vec::new(),
duration: 1.0,
looping: true,
tracks: Vec::new(),
root: None,
},
declared_weight: 1.0,
fade_in_secs: 0.0,
}
}
fn flat_bucket(target: SkinnedMeshHandle) -> BTreeMap<SkinnedMeshHandle, TargetState> {
BTreeMap::from([(
target,
TargetState {
clips: vec![clip_entry()],
mode: TargetMode::Flat(FlatState {
current_weights: vec![1.0],
transition: None,
}),
},
)])
}
fn handle(name: &str) -> SkinnedMeshHandle {
SkinnedMeshHandle(intern(name).0)
}
struct TestWorld {
components: ComponentStorage,
blob: BlobData,
profile: FrameProfile,
resources: Resources,
scratch: crate::ecs::Arena,
}
impl TestWorld {
fn new() -> Self {
Self {
components: ComponentStorage::default(),
blob: BlobData::new(vec![Some(Vec::new())]),
profile: FrameProfile::default(),
resources: Resources::new(),
scratch: crate::ecs::Arena::with_capacity(64 * 1024),
}
}
fn push<C: ComponentSlot>(&mut self, c: C) {
self.components.push_typed(c);
}
fn ctx(&mut self) -> PipelineContext<'_> {
PipelineContext {
components: &mut self.components,
blob: &mut self.blob,
profile: &mut self.profile,
resources: &mut self.resources,
frame: crate::ecs::FrameContext::new(&self.scratch),
}
}
fn count<C: ComponentSlot>(&mut self) -> usize {
self.ctx().query::<C>().count()
}
}
fn is_graph(targets: &BTreeMap<SkinnedMeshHandle, TargetState>, t: SkinnedMeshHandle) -> bool {
matches!(targets[&t].mode, TargetMode::Graph(_))
}
fn graph_json(target: &str, clip: &str) -> serde_json::Value {
serde_json::json!({
"target": target,
"parameters": [{"name": "speed", "default": 1.5}],
"states": [{"name": "idle", "clip": clip}],
})
}
fn parse(v: serde_json::Value) -> AnimationGraph {
crate::ecs::asset_id::ensure_name_resolver();
serde_json::from_value(v).unwrap()
}
#[test]
fn install_graphs_ignores_a_graph_with_no_target() {
let mut w = TestWorld::new();
w.push(AnimationGraph::default());
let mut targets = BTreeMap::new();
assert_eq!(
install_graphs(&mut targets, &mut w.ctx(), &HashMap::new()),
0
);
}
#[test]
fn install_graphs_ignores_a_graph_whose_target_has_no_clips() {
let target = handle("gi_no_clips");
let mut w = TestWorld::new();
w.push(parse(graph_json("gi_no_clips", "gi_missing_clip")));
let mut targets = BTreeMap::new();
assert_eq!(
install_graphs(&mut targets, &mut w.ctx(), &HashMap::new()),
0
);
assert!(!targets.contains_key(&target));
assert_eq!(w.count::<AnimationParams>(), 0, "no params published");
}
#[test]
fn install_graphs_falls_back_to_the_blend_when_compile_fails() {
let target = handle("gi_bad_compile");
let mut w = TestWorld::new();
w.push(parse(graph_json("gi_bad_compile", "gi_unresolvable")));
let mut targets = flat_bucket(target);
assert_eq!(
install_graphs(&mut targets, &mut w.ctx(), &HashMap::new()),
0
);
assert!(
!is_graph(&targets, target),
"the bucket keeps its flat drive"
);
assert_eq!(w.count::<AnimationParams>(), 0);
}
#[test]
fn install_graphs_rejects_a_clip_owned_by_another_target() {
let target = handle("gi_owner");
let clip = intern("gi_owned_clip");
let mut w = TestWorld::new();
w.push(parse(graph_json("gi_owner", "gi_owned_clip")));
let mut targets = flat_bucket(target);
let slots = HashMap::from([(clip, (handle("gi_stranger"), 0))]);
assert_eq!(install_graphs(&mut targets, &mut w.ctx(), &slots), 0);
assert!(!is_graph(&targets, target));
}
#[test]
fn install_graphs_takes_the_bucket_and_seeds_params() {
let target = handle("gi_ok");
let clip = intern("gi_ok_clip");
let mut w = TestWorld::new();
w.push(parse(graph_json("gi_ok", "gi_ok_clip")));
let mut targets = flat_bucket(target);
let slots = HashMap::from([(clip, (target, 0usize))]);
assert_eq!(install_graphs(&mut targets, &mut w.ctx(), &slots), 1);
assert!(is_graph(&targets, target));
let ctx = w.ctx();
let params: Vec<&AnimationParams> = ctx.query::<AnimationParams>().collect();
assert_eq!(params.len(), 1);
assert_eq!(params[0].target, target);
assert_eq!(
params[0].values,
vec![1.5],
"seeded from the declared default"
);
assert_eq!(
w.count::<crate::components::GroundProbes>(),
0,
"no chains, no probe exchange"
);
}
#[test]
fn install_graphs_disables_ik_when_the_target_has_no_skeleton() {
let target = handle("gi_no_skel");
let clip = intern("gi_no_skel_clip");
let mut v = graph_json("gi_no_skel", "gi_no_skel_clip");
v["ik_chains"] = serde_json::json!([{"joints": ["hip", "knee", "foot"],
"pole": [0.0, 0.0, 1.0]}]);
let mut w = TestWorld::new();
w.push(parse(v));
let mut targets = flat_bucket(target);
let slots = HashMap::from([(clip, (target, 0usize))]);
assert_eq!(install_graphs(&mut targets, &mut w.ctx(), &slots), 1);
let TargetMode::Graph(g) = &targets[&target].mode else {
panic!("graph installed");
};
assert!(g.chains.is_empty(), "IK disabled without a skeleton");
assert_eq!(w.count::<crate::components::GroundProbes>(), 0);
}
fn graph_target(name: &str) -> GraphTarget {
let clip = intern(&format!("{name}_clip"));
let g = parse(graph_json(name, &format!("{name}_clip")));
let compiled = g
.compile(|id| (id == clip).then_some((0, 1.0, true)))
.unwrap();
GraphTarget {
cursor: GraphCursor::start(&compiled),
params: compiled.default_params(),
graph: compiled,
pending: Vec::new(),
chains: Vec::new(),
}
}
#[test]
fn step_target_flushes_queued_writes_into_the_component() {
let target = handle("gs_component");
let mut w = TestWorld::new();
w.push(AnimationParams::new(target, vec![0.0]));
let mut g = graph_target("gs_component");
g.pending.push((0, 3.0));
step_target(&mut g, target, &mut w.ctx(), 0.1);
assert!(g.pending.is_empty(), "the queue drains");
assert_eq!(g.params, vec![3.0], "snapshot taken from the component");
let ctx = w.ctx();
let params: Vec<&AnimationParams> = ctx.query::<AnimationParams>().collect();
assert_eq!(params[0].values, vec![3.0], "the write landed in the store");
}
#[test]
fn step_target_keeps_applying_writes_without_the_component() {
let target = handle("gs_orphan");
let mut w = TestWorld::new();
let mut g = graph_target("gs_orphan");
g.pending.push((0, 4.0));
g.pending.push((9, 1.0));
step_target(&mut g, target, &mut w.ctx(), 0.1);
assert!(g.pending.is_empty());
assert_eq!(g.params, vec![4.0], "the snapshot took the write");
}
#[test]
fn step_target_ignores_another_targets_params() {
let target = handle("gs_mine");
let mut w = TestWorld::new();
w.push(AnimationParams::new(handle("gs_theirs"), vec![9.0]));
let mut g = graph_target("gs_mine");
g.pending.push((0, 2.0));
step_target(&mut g, target, &mut w.ctx(), 0.1);
assert_eq!(g.params, vec![2.0], "the stranger's values are not read");
let ctx = w.ctx();
let params: Vec<&AnimationParams> = ctx.query::<AnimationParams>().collect();
assert_eq!(params[0].values, vec![9.0], "and are not written either");
}
}