use crate::app::anim_runtime::{AnimCommand, GraphStateReport};
use crate::ecs::SkinnedMeshHandle;
use crate::gfx::anim_graph::normalized_time;
use super::flat::Transition;
use super::graph::GraphTarget;
use super::{AnimationSystem, TargetMode};
impl AnimationSystem {
pub fn apply_runtime_commands(&mut self) {
let now = std::time::Instant::now();
let start = *self.start.get_or_insert(now);
let t = (now - start).as_secs_f32();
self.drain_runtime_commands(t);
}
fn drain_runtime_commands(&mut self, now_secs: f32) {
for cmd in crate::app::anim_runtime::drain() {
match cmd {
AnimCommand::Crossfade { req, reply } => {
let target = self.name_index.get(req.target);
let _ = reply.send(self.apply_crossfade(
target,
req.weights,
req.duration_secs,
now_secs,
));
}
AnimCommand::SetParam { req, reply } => {
let target = self.name_index.get(req.target);
let _ = reply.send(self.queue_param(target, &req.name, req.value));
}
AnimCommand::QueryState { target, reply } => {
let target = self.name_index.get(target);
let _ = reply.send(self.graph_report(target));
}
}
}
}
pub(super) fn apply_crossfade(
&mut self,
target: SkinnedMeshHandle,
weights: Vec<f32>,
duration_secs: f32,
now_secs: f32,
) -> Result<(), String> {
let Some(state) = self.targets.get_mut(&target) else {
return Err(format!(
"anim-crossfade: no Animation registered for target {target:?}"
));
};
let TargetMode::Flat(flat) = &mut state.mode else {
return Err(format!(
"anim-crossfade: target {target:?} is graph-driven; set a parameter with \
anim-param instead"
));
};
if weights.len() != state.clips.len() {
return Err(format!(
"anim-crossfade: weight count {} does not match clip count {} for target {:?}",
weights.len(),
state.clips.len(),
target,
));
}
flat.transition = Some(Transition {
source_weights: flat.current_weights.clone(),
target_weights: weights,
start_secs: now_secs,
duration_secs: duration_secs.max(0.0),
});
Ok(())
}
pub(super) fn queue_param(
&mut self,
target: SkinnedMeshHandle,
name: &str,
value: f32,
) -> Result<(), String> {
let g = self.graph_target_mut(&target, "anim-param")?;
let Some(index) = g.graph.param_index(name) else {
return Err(format!(
"anim-param: graph for target {target:?} declares no parameter '{name}'"
));
};
g.pending.push((index, value));
Ok(())
}
pub(super) fn graph_report(
&mut self,
target: SkinnedMeshHandle,
) -> Result<GraphStateReport, String> {
let g = self.graph_target_mut(&target, "anim-state")?;
let state = &g.graph.states[g.cursor.state];
let fade = g.cursor.fade.as_ref();
let weights = state.play.weights(&g.params);
let effective_duration = state.play.effective_duration(&weights);
Ok(GraphStateReport {
state: state.name.clone(),
clock_secs: normalized_time(state, g.cursor.clock, &g.params) * effective_duration,
fading_from: fade.map(|f| g.graph.states[f.from_state].name.clone()),
fade_progress: fade.map(|f| f.progress()),
blend_weights: (weights.len() > 1).then_some(weights),
params: g
.graph
.params
.iter()
.zip(&g.params)
.map(|(spec, &value)| (spec.name.clone(), value))
.collect(),
})
}
fn graph_target_mut(
&mut self,
target: &SkinnedMeshHandle,
cmd: &str,
) -> Result<&mut GraphTarget, String> {
let Some(state) = self.targets.get_mut(target) else {
return Err(format!(
"{cmd}: no animation registered for target {target:?}"
));
};
match &mut state.mode {
TargetMode::Graph(g) => Ok(g),
TargetMode::Flat(_) => Err(format!(
"{cmd}: target {target:?} has no AnimationGraph (its clips blend by weight; \
use anim-crossfade)"
)),
}
}
}
#[cfg(test)]
mod tests {
use super::super::TargetState;
use super::super::flat::{ClipEntry, FlatState};
use super::*;
use crate::app::anim_runtime::{CrossfadeRequest, SetParamRequest};
use crate::components::AnimationGraph;
use crate::ecs::asset_id::AssetId;
use crate::gfx::anim_graph::GraphCursor;
use crate::gfx::skeleton::AnimationClip;
use crate::gfx::skinned_mesh_map::SkinnedMeshNameIndex;
const TARGET: SkinnedMeshHandle = SkinnedMeshHandle(1);
const MISSING: SkinnedMeshHandle = SkinnedMeshHandle(9);
const NAME: AssetId = AssetId(77);
fn clip_entry() -> ClipEntry {
ClipEntry {
clip: 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_system(clips: usize) -> AnimationSystem {
let mut sys = AnimationSystem::new();
sys.targets.insert(
TARGET,
TargetState {
clips: (0..clips).map(|_| clip_entry()).collect(),
mode: TargetMode::Flat(FlatState {
current_weights: vec![1.0; clips],
transition: None,
}),
},
);
sys
}
fn graph_system(fade_secs: f32) -> AnimationSystem {
crate::ecs::asset_id::ensure_name_resolver();
let g: AnimationGraph = serde_json::from_value(serde_json::json!({
"parameters": [{"name": "speed", "default": 0.0}],
"initial": "idle",
"states": [
{"name": "idle", "clip": "cmd_idle_clip"},
{"name": "run", "clip": "cmd_run_clip"}
],
"transitions": [
{"from": "idle", "to": "run", "duration_secs": fade_secs,
"conditions": [{"parameter": "speed", "op": "gt", "value": 0.5}]}
]
}))
.unwrap();
let graph = g.compile(|_| Some((0, 1.0, true))).unwrap();
let params = graph.default_params();
let mut sys = AnimationSystem::new();
sys.targets.insert(
TARGET,
TargetState {
clips: vec![clip_entry()],
mode: TargetMode::Graph(GraphTarget {
cursor: GraphCursor::start(&graph),
graph,
params,
pending: Vec::new(),
chains: Vec::new(),
}),
},
);
sys
}
fn name_index() -> SkinnedMeshNameIndex {
SkinnedMeshNameIndex(std::collections::HashMap::from([(NAME, TARGET)]))
}
fn transition(sys: &mut AnimationSystem) -> Option<&Transition> {
match &sys.targets.get(&TARGET)?.mode {
TargetMode::Flat(f) => f.transition.as_ref(),
TargetMode::Graph(_) => None,
}
}
fn advance(sys: &mut AnimationSystem, dt: f32) {
let Some(TargetState {
mode: TargetMode::Graph(g),
..
}) = sys.targets.get_mut(&TARGET)
else {
panic!("graph bucket");
};
let params = g.params.clone();
g.cursor.advance(&g.graph, ¶ms, dt);
}
fn queue_guard() -> std::sync::MutexGuard<'static, ()> {
let g = crate::app::anim_runtime::TEST_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
let _ = crate::app::anim_runtime::drain();
g
}
#[test]
fn apply_crossfade_rejects_an_unregistered_target() {
let mut sys = AnimationSystem::new();
let err = sys
.apply_crossfade(MISSING, vec![1.0], 0.0, 0.0)
.unwrap_err();
assert!(err.contains("anim-crossfade"), "{err}");
assert!(err.contains("no Animation registered"), "{err}");
}
#[test]
fn apply_crossfade_rejects_a_weight_count_that_misses_the_clips() {
let mut sys = flat_system(2);
let err = sys
.apply_crossfade(TARGET, vec![1.0], 0.0, 0.0)
.unwrap_err();
assert!(err.contains("weight count 1"), "{err}");
assert!(err.contains("clip count 2"), "{err}");
assert!(transition(&mut sys).is_none(), "no ramp was installed");
}
#[test]
fn apply_crossfade_ramps_from_the_live_weights() {
let mut sys = flat_system(2);
sys.apply_crossfade(TARGET, vec![0.0, 1.0], 0.5, 3.0)
.unwrap();
let tr = transition(&mut sys).expect("ramp installed");
assert_eq!(tr.source_weights, vec![1.0, 1.0]);
assert_eq!(tr.target_weights, vec![0.0, 1.0]);
assert_eq!(tr.start_secs, 3.0);
assert_eq!(tr.duration_secs, 0.5);
}
#[test]
fn apply_crossfade_clamps_a_negative_duration_to_a_snap() {
let mut sys = flat_system(1);
sys.apply_crossfade(TARGET, vec![0.5], -1.0, 0.0).unwrap();
assert_eq!(transition(&mut sys).unwrap().duration_secs, 0.0);
}
#[test]
fn a_second_crossfade_supersedes_the_ramp_in_flight() {
let mut sys = flat_system(1);
sys.apply_crossfade(TARGET, vec![0.0], 1.0, 0.0).unwrap();
sys.apply_crossfade(TARGET, vec![0.25], 2.0, 4.0).unwrap();
let tr = transition(&mut sys).unwrap();
assert_eq!(tr.target_weights, vec![0.25]);
assert_eq!(tr.start_secs, 4.0);
}
#[test]
fn graph_commands_reject_an_unregistered_target() {
let mut sys = AnimationSystem::new();
let err = sys.queue_param(MISSING, "speed", 1.0).unwrap_err();
assert!(err.contains("anim-param"), "{err}");
assert!(err.contains("no animation registered"), "{err}");
let err = sys.graph_report(MISSING).unwrap_err();
assert!(err.contains("anim-state"), "{err}");
assert!(err.contains("no animation registered"), "{err}");
}
#[test]
fn queue_param_rejects_a_parameter_the_graph_does_not_declare() {
let mut sys = graph_system(0.0);
let err = sys.queue_param(TARGET, "nope", 1.0).unwrap_err();
assert!(err.contains("declares no parameter 'nope'"), "{err}");
let report = sys.graph_report(TARGET).unwrap();
assert_eq!(report.params, vec![("speed".to_string(), 0.0)]);
}
#[test]
fn queue_param_holds_the_write_against_the_parameter_index() {
let mut sys = graph_system(0.0);
sys.queue_param(TARGET, "speed", 2.5).unwrap();
let Some(TargetState {
mode: TargetMode::Graph(g),
..
}) = sys.targets.get(&TARGET)
else {
panic!("graph bucket");
};
assert_eq!(g.pending, vec![(0, 2.5)]);
}
#[test]
fn graph_report_of_a_parked_graph_carries_no_fade() {
let mut sys = graph_system(0.5);
let report = sys.graph_report(TARGET).unwrap();
assert_eq!(report.state, "idle");
assert_eq!(report.clock_secs, 0.0);
assert!(report.fading_from.is_none());
assert!(report.fade_progress.is_none());
assert!(
report.blend_weights.is_none(),
"a single-clip state reports no blend weights"
);
}
#[test]
fn graph_report_carries_the_fade_while_a_transition_is_in_flight() {
let mut sys = graph_system(0.5);
sys.queue_param(TARGET, "speed", 2.0).unwrap();
if let Some(TargetState {
mode: TargetMode::Graph(g),
..
}) = sys.targets.get_mut(&TARGET)
{
g.params = vec![2.0];
}
advance(&mut sys, 0.1);
advance(&mut sys, 0.1);
let report = sys.graph_report(TARGET).unwrap();
assert_eq!(report.state, "run");
assert_eq!(report.fading_from.as_deref(), Some("idle"));
let progress = report.fade_progress.unwrap();
assert!((progress - 0.2).abs() < 1e-4, "{progress}");
assert!((report.clock_secs - 0.1).abs() < 1e-4, "{report:?}");
}
#[test]
fn graph_report_drops_the_fade_once_it_completes() {
let mut sys = graph_system(0.5);
if let Some(TargetState {
mode: TargetMode::Graph(g),
..
}) = sys.targets.get_mut(&TARGET)
{
g.params = vec![2.0];
}
advance(&mut sys, 0.1);
advance(&mut sys, 0.6);
let report = sys.graph_report(TARGET).unwrap();
assert_eq!(report.state, "run");
assert!(report.fading_from.is_none());
assert!(report.fade_progress.is_none());
}
#[test]
fn drain_applies_a_crossfade_addressed_by_name() {
let _guard = queue_guard();
let mut sys = flat_system(2);
sys.name_index = name_index();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
crate::app::anim_runtime::enqueue(AnimCommand::Crossfade {
req: CrossfadeRequest {
target: NAME,
weights: vec![0.0, 1.0],
duration_secs: 0.25,
},
reply: tx,
});
sys.drain_runtime_commands(2.0);
assert_eq!(rx.try_recv().unwrap(), Ok(()));
let tr = transition(&mut sys).expect("the named target's bucket ramped");
assert_eq!(tr.target_weights, vec![0.0, 1.0]);
assert_eq!(tr.start_secs, 2.0, "the drain's clock anchors the ramp");
}
#[test]
fn drain_answers_param_writes_and_state_queries() {
let _guard = queue_guard();
let mut sys = graph_system(0.0);
sys.name_index = name_index();
let (param_tx, param_rx) = std::sync::mpsc::sync_channel(1);
crate::app::anim_runtime::enqueue(AnimCommand::SetParam {
req: SetParamRequest {
target: NAME,
name: "speed".to_string(),
value: 4.0,
},
reply: param_tx,
});
let (query_tx, query_rx) = std::sync::mpsc::sync_channel(1);
crate::app::anim_runtime::enqueue(AnimCommand::QueryState {
target: NAME,
reply: query_tx,
});
sys.drain_runtime_commands(0.0);
assert_eq!(param_rx.try_recv().unwrap(), Ok(()));
assert_eq!(query_rx.try_recv().unwrap().unwrap().state, "idle");
}
#[test]
fn drain_replies_to_a_command_it_cannot_apply() {
let _guard = queue_guard();
let mut sys = AnimationSystem::new();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
crate::app::anim_runtime::enqueue(AnimCommand::QueryState {
target: NAME,
reply: tx,
});
sys.drain_runtime_commands(0.0);
assert!(rx.try_recv().unwrap().is_err());
}
#[test]
fn apply_runtime_commands_anchors_the_clock_and_answers() {
let _guard = queue_guard();
let mut sys = graph_system(0.0);
sys.name_index = name_index();
let (tx, rx) = std::sync::mpsc::sync_channel(1);
crate::app::anim_runtime::enqueue(AnimCommand::QueryState {
target: NAME,
reply: tx,
});
sys.apply_runtime_commands();
assert_eq!(rx.try_recv().unwrap().unwrap().state, "idle");
assert!(sys.start.is_some(), "the drive shares `step`'s origin");
}
#[test]
fn draining_an_empty_queue_changes_nothing() {
let _guard = queue_guard();
let mut sys = flat_system(1);
sys.drain_runtime_commands(1.0);
assert!(transition(&mut sys).is_none());
}
}