1use crate::app::anim_runtime::{AnimCommand, GraphStateReport};
11use crate::ecs::SkinnedMeshHandle;
12use crate::gfx::anim_graph::normalized_time;
13
14use super::flat::Transition;
15use super::graph::GraphTarget;
16use super::{AnimationSystem, TargetMode};
17
18impl AnimationSystem {
19 pub fn apply_runtime_commands(&mut self) {
25 let now = std::time::Instant::now();
26 let start = *self.start.get_or_insert(now);
27 let t = (now - start).as_secs_f32();
28 self.drain_runtime_commands(t);
29 }
30
31 fn drain_runtime_commands(&mut self, now_secs: f32) {
36 for cmd in crate::app::anim_runtime::drain() {
40 match cmd {
41 AnimCommand::Crossfade { req, reply } => {
42 let target = self.name_index.get(req.target);
43 let _ = reply.send(self.apply_crossfade(
44 target,
45 req.weights,
46 req.duration_secs,
47 now_secs,
48 ));
49 }
50 AnimCommand::SetParam { req, reply } => {
51 let target = self.name_index.get(req.target);
52 let _ = reply.send(self.queue_param(target, &req.name, req.value));
53 }
54 AnimCommand::QueryState { target, reply } => {
55 let target = self.name_index.get(target);
56 let _ = reply.send(self.graph_report(target));
57 }
58 }
59 }
60 }
61
62 pub(super) fn apply_crossfade(
66 &mut self,
67 target: SkinnedMeshHandle,
68 weights: Vec<f32>,
69 duration_secs: f32,
70 now_secs: f32,
71 ) -> Result<(), String> {
72 let Some(state) = self.targets.get_mut(&target) else {
73 return Err(format!(
74 "anim-crossfade: no Animation registered for target {target:?}"
75 ));
76 };
77 let TargetMode::Flat(flat) = &mut state.mode else {
78 return Err(format!(
79 "anim-crossfade: target {target:?} is graph-driven; set a parameter with \
80 anim-param instead"
81 ));
82 };
83 if weights.len() != state.clips.len() {
84 return Err(format!(
85 "anim-crossfade: weight count {} does not match clip count {} for target {:?}",
86 weights.len(),
87 state.clips.len(),
88 target,
89 ));
90 }
91 flat.transition = Some(Transition {
92 source_weights: flat.current_weights.clone(),
93 target_weights: weights,
94 start_secs: now_secs,
95 duration_secs: duration_secs.max(0.0),
96 });
97 Ok(())
98 }
99
100 pub(super) fn queue_param(
104 &mut self,
105 target: SkinnedMeshHandle,
106 name: &str,
107 value: f32,
108 ) -> Result<(), String> {
109 let g = self.graph_target_mut(&target, "anim-param")?;
110 let Some(index) = g.graph.param_index(name) else {
111 return Err(format!(
112 "anim-param: graph for target {target:?} declares no parameter '{name}'"
113 ));
114 };
115 g.pending.push((index, value));
116 Ok(())
117 }
118
119 pub(super) fn graph_report(
122 &mut self,
123 target: SkinnedMeshHandle,
124 ) -> Result<GraphStateReport, String> {
125 let g = self.graph_target_mut(&target, "anim-state")?;
126 let state = &g.graph.states[g.cursor.state];
127 let fade = g.cursor.fade.as_ref();
128 let weights = state.play.weights(&g.params);
129 let effective_duration = state.play.effective_duration(&weights);
130 Ok(GraphStateReport {
131 state: state.name.clone(),
132 clock_secs: normalized_time(state, g.cursor.clock, &g.params) * effective_duration,
133 fading_from: fade.map(|f| g.graph.states[f.from_state].name.clone()),
134 fade_progress: fade.map(|f| f.progress()),
135 blend_weights: (weights.len() > 1).then_some(weights),
138 params: g
139 .graph
140 .params
141 .iter()
142 .zip(&g.params)
143 .map(|(spec, &value)| (spec.name.clone(), value))
144 .collect(),
145 })
146 }
147
148 fn graph_target_mut(
149 &mut self,
150 target: &SkinnedMeshHandle,
151 cmd: &str,
152 ) -> Result<&mut GraphTarget, String> {
153 let Some(state) = self.targets.get_mut(target) else {
154 return Err(format!(
155 "{cmd}: no animation registered for target {target:?}"
156 ));
157 };
158 match &mut state.mode {
159 TargetMode::Graph(g) => Ok(g),
160 TargetMode::Flat(_) => Err(format!(
161 "{cmd}: target {target:?} has no AnimationGraph (its clips blend by weight; \
162 use anim-crossfade)"
163 )),
164 }
165 }
166}
167
168#[cfg(test)]
169mod tests {
170 use super::super::TargetState;
171 use super::super::flat::{ClipEntry, FlatState};
172 use super::*;
173 use crate::app::anim_runtime::{CrossfadeRequest, SetParamRequest};
174 use crate::components::AnimationGraph;
175 use crate::ecs::asset_id::AssetId;
176 use crate::gfx::anim_graph::GraphCursor;
177 use crate::gfx::skeleton::AnimationClip;
178 use crate::gfx::skinned_mesh_map::SkinnedMeshNameIndex;
179
180 const TARGET: SkinnedMeshHandle = SkinnedMeshHandle(1);
181 const MISSING: SkinnedMeshHandle = SkinnedMeshHandle(9);
182 const NAME: AssetId = AssetId(77);
185
186 fn clip_entry() -> ClipEntry {
188 ClipEntry {
189 clip: AnimationClip {
190 morph_keys: Vec::new(),
191 duration: 1.0,
192 looping: true,
193 tracks: Vec::new(),
194 root: None,
195 },
196 declared_weight: 1.0,
197 fade_in_secs: 0.0,
198 }
199 }
200
201 fn flat_system(clips: usize) -> AnimationSystem {
203 let mut sys = AnimationSystem::new();
204 sys.targets.insert(
205 TARGET,
206 TargetState {
207 clips: (0..clips).map(|_| clip_entry()).collect(),
208 mode: TargetMode::Flat(FlatState {
209 current_weights: vec![1.0; clips],
210 transition: None,
211 }),
212 },
213 );
214 sys
215 }
216
217 fn graph_system(fade_secs: f32) -> AnimationSystem {
221 crate::ecs::asset_id::ensure_name_resolver();
222 let g: AnimationGraph = serde_json::from_value(serde_json::json!({
223 "parameters": [{"name": "speed", "default": 0.0}],
224 "initial": "idle",
225 "states": [
226 {"name": "idle", "clip": "cmd_idle_clip"},
227 {"name": "run", "clip": "cmd_run_clip"}
228 ],
229 "transitions": [
230 {"from": "idle", "to": "run", "duration_secs": fade_secs,
231 "conditions": [{"parameter": "speed", "op": "gt", "value": 0.5}]}
232 ]
233 }))
234 .unwrap();
235 let graph = g.compile(|_| Some((0, 1.0, true))).unwrap();
236 let params = graph.default_params();
237 let mut sys = AnimationSystem::new();
238 sys.targets.insert(
239 TARGET,
240 TargetState {
241 clips: vec![clip_entry()],
242 mode: TargetMode::Graph(GraphTarget {
243 cursor: GraphCursor::start(&graph),
244 graph,
245 params,
246 pending: Vec::new(),
247 chains: Vec::new(),
248 }),
249 },
250 );
251 sys
252 }
253
254 fn name_index() -> SkinnedMeshNameIndex {
255 SkinnedMeshNameIndex(std::collections::HashMap::from([(NAME, TARGET)]))
256 }
257
258 fn transition(sys: &mut AnimationSystem) -> Option<&Transition> {
260 match &sys.targets.get(&TARGET)?.mode {
261 TargetMode::Flat(f) => f.transition.as_ref(),
262 TargetMode::Graph(_) => None,
263 }
264 }
265
266 fn advance(sys: &mut AnimationSystem, dt: f32) {
269 let Some(TargetState {
270 mode: TargetMode::Graph(g),
271 ..
272 }) = sys.targets.get_mut(&TARGET)
273 else {
274 panic!("graph bucket");
275 };
276 let params = g.params.clone();
277 g.cursor.advance(&g.graph, ¶ms, dt);
278 }
279
280 fn queue_guard() -> std::sync::MutexGuard<'static, ()> {
284 let g = crate::app::anim_runtime::TEST_LOCK
285 .lock()
286 .unwrap_or_else(|e| e.into_inner());
287 let _ = crate::app::anim_runtime::drain();
288 g
289 }
290
291 #[test]
294 fn apply_crossfade_rejects_an_unregistered_target() {
295 let mut sys = AnimationSystem::new();
296 let err = sys
297 .apply_crossfade(MISSING, vec![1.0], 0.0, 0.0)
298 .unwrap_err();
299 assert!(err.contains("anim-crossfade"), "{err}");
300 assert!(err.contains("no Animation registered"), "{err}");
301 }
302
303 #[test]
306 fn apply_crossfade_rejects_a_weight_count_that_misses_the_clips() {
307 let mut sys = flat_system(2);
308 let err = sys
309 .apply_crossfade(TARGET, vec![1.0], 0.0, 0.0)
310 .unwrap_err();
311 assert!(err.contains("weight count 1"), "{err}");
312 assert!(err.contains("clip count 2"), "{err}");
313 assert!(transition(&mut sys).is_none(), "no ramp was installed");
314 }
315
316 #[test]
319 fn apply_crossfade_ramps_from_the_live_weights() {
320 let mut sys = flat_system(2);
321 sys.apply_crossfade(TARGET, vec![0.0, 1.0], 0.5, 3.0)
322 .unwrap();
323 let tr = transition(&mut sys).expect("ramp installed");
324 assert_eq!(tr.source_weights, vec![1.0, 1.0]);
325 assert_eq!(tr.target_weights, vec![0.0, 1.0]);
326 assert_eq!(tr.start_secs, 3.0);
327 assert_eq!(tr.duration_secs, 0.5);
328 }
329
330 #[test]
333 fn apply_crossfade_clamps_a_negative_duration_to_a_snap() {
334 let mut sys = flat_system(1);
335 sys.apply_crossfade(TARGET, vec![0.5], -1.0, 0.0).unwrap();
336 assert_eq!(transition(&mut sys).unwrap().duration_secs, 0.0);
337 }
338
339 #[test]
341 fn a_second_crossfade_supersedes_the_ramp_in_flight() {
342 let mut sys = flat_system(1);
343 sys.apply_crossfade(TARGET, vec![0.0], 1.0, 0.0).unwrap();
344 sys.apply_crossfade(TARGET, vec![0.25], 2.0, 4.0).unwrap();
345 let tr = transition(&mut sys).unwrap();
346 assert_eq!(tr.target_weights, vec![0.25]);
347 assert_eq!(tr.start_secs, 4.0);
348 }
349
350 #[test]
353 fn graph_commands_reject_an_unregistered_target() {
354 let mut sys = AnimationSystem::new();
355 let err = sys.queue_param(MISSING, "speed", 1.0).unwrap_err();
356 assert!(err.contains("anim-param"), "{err}");
357 assert!(err.contains("no animation registered"), "{err}");
358 let err = sys.graph_report(MISSING).unwrap_err();
359 assert!(err.contains("anim-state"), "{err}");
360 assert!(err.contains("no animation registered"), "{err}");
361 }
362
363 #[test]
365 fn queue_param_rejects_a_parameter_the_graph_does_not_declare() {
366 let mut sys = graph_system(0.0);
367 let err = sys.queue_param(TARGET, "nope", 1.0).unwrap_err();
368 assert!(err.contains("declares no parameter 'nope'"), "{err}");
369 let report = sys.graph_report(TARGET).unwrap();
370 assert_eq!(report.params, vec![("speed".to_string(), 0.0)]);
371 }
372
373 #[test]
376 fn queue_param_holds_the_write_against_the_parameter_index() {
377 let mut sys = graph_system(0.0);
378 sys.queue_param(TARGET, "speed", 2.5).unwrap();
379 let Some(TargetState {
380 mode: TargetMode::Graph(g),
381 ..
382 }) = sys.targets.get(&TARGET)
383 else {
384 panic!("graph bucket");
385 };
386 assert_eq!(g.pending, vec![(0, 2.5)]);
387 }
388
389 #[test]
391 fn graph_report_of_a_parked_graph_carries_no_fade() {
392 let mut sys = graph_system(0.5);
393 let report = sys.graph_report(TARGET).unwrap();
394 assert_eq!(report.state, "idle");
395 assert_eq!(report.clock_secs, 0.0);
396 assert!(report.fading_from.is_none());
397 assert!(report.fade_progress.is_none());
398 assert!(
399 report.blend_weights.is_none(),
400 "a single-clip state reports no blend weights"
401 );
402 }
403
404 #[test]
407 fn graph_report_carries_the_fade_while_a_transition_is_in_flight() {
408 let mut sys = graph_system(0.5);
409 sys.queue_param(TARGET, "speed", 2.0).unwrap();
410 if let Some(TargetState {
413 mode: TargetMode::Graph(g),
414 ..
415 }) = sys.targets.get_mut(&TARGET)
416 {
417 g.params = vec![2.0];
418 }
419 advance(&mut sys, 0.1);
422 advance(&mut sys, 0.1);
423
424 let report = sys.graph_report(TARGET).unwrap();
425 assert_eq!(report.state, "run");
426 assert_eq!(report.fading_from.as_deref(), Some("idle"));
427 let progress = report.fade_progress.unwrap();
428 assert!((progress - 0.2).abs() < 1e-4, "{progress}");
429 assert!((report.clock_secs - 0.1).abs() < 1e-4, "{report:?}");
431 }
432
433 #[test]
435 fn graph_report_drops_the_fade_once_it_completes() {
436 let mut sys = graph_system(0.5);
437 if let Some(TargetState {
438 mode: TargetMode::Graph(g),
439 ..
440 }) = sys.targets.get_mut(&TARGET)
441 {
442 g.params = vec![2.0];
443 }
444 advance(&mut sys, 0.1);
445 advance(&mut sys, 0.6);
446 let report = sys.graph_report(TARGET).unwrap();
447 assert_eq!(report.state, "run");
448 assert!(report.fading_from.is_none());
449 assert!(report.fade_progress.is_none());
450 }
451
452 #[test]
456 fn drain_applies_a_crossfade_addressed_by_name() {
457 let _guard = queue_guard();
458 let mut sys = flat_system(2);
459 sys.name_index = name_index();
460 let (tx, rx) = std::sync::mpsc::sync_channel(1);
461 crate::app::anim_runtime::enqueue(AnimCommand::Crossfade {
462 req: CrossfadeRequest {
463 target: NAME,
464 weights: vec![0.0, 1.0],
465 duration_secs: 0.25,
466 },
467 reply: tx,
468 });
469 sys.drain_runtime_commands(2.0);
470
471 assert_eq!(rx.try_recv().unwrap(), Ok(()));
472 let tr = transition(&mut sys).expect("the named target's bucket ramped");
473 assert_eq!(tr.target_weights, vec![0.0, 1.0]);
474 assert_eq!(tr.start_secs, 2.0, "the drain's clock anchors the ramp");
475 }
476
477 #[test]
480 fn drain_answers_param_writes_and_state_queries() {
481 let _guard = queue_guard();
482 let mut sys = graph_system(0.0);
483 sys.name_index = name_index();
484 let (param_tx, param_rx) = std::sync::mpsc::sync_channel(1);
485 crate::app::anim_runtime::enqueue(AnimCommand::SetParam {
486 req: SetParamRequest {
487 target: NAME,
488 name: "speed".to_string(),
489 value: 4.0,
490 },
491 reply: param_tx,
492 });
493 let (query_tx, query_rx) = std::sync::mpsc::sync_channel(1);
494 crate::app::anim_runtime::enqueue(AnimCommand::QueryState {
495 target: NAME,
496 reply: query_tx,
497 });
498 sys.drain_runtime_commands(0.0);
499
500 assert_eq!(param_rx.try_recv().unwrap(), Ok(()));
501 assert_eq!(query_rx.try_recv().unwrap().unwrap().state, "idle");
502 }
503
504 #[test]
507 fn drain_replies_to_a_command_it_cannot_apply() {
508 let _guard = queue_guard();
509 let mut sys = AnimationSystem::new();
510 let (tx, rx) = std::sync::mpsc::sync_channel(1);
511 crate::app::anim_runtime::enqueue(AnimCommand::QueryState {
512 target: NAME,
513 reply: tx,
514 });
515 sys.drain_runtime_commands(0.0);
516 assert!(rx.try_recv().unwrap().is_err());
517 }
518
519 #[test]
523 fn apply_runtime_commands_anchors_the_clock_and_answers() {
524 let _guard = queue_guard();
525 let mut sys = graph_system(0.0);
526 sys.name_index = name_index();
527 let (tx, rx) = std::sync::mpsc::sync_channel(1);
528 crate::app::anim_runtime::enqueue(AnimCommand::QueryState {
529 target: NAME,
530 reply: tx,
531 });
532 sys.apply_runtime_commands();
533 assert_eq!(rx.try_recv().unwrap().unwrap().state, "idle");
534 assert!(sys.start.is_some(), "the drive shares `step`'s origin");
535 }
536
537 #[test]
539 fn draining_an_empty_queue_changes_nothing() {
540 let _guard = queue_guard();
541 let mut sys = flat_system(1);
542 sys.drain_runtime_commands(1.0);
543 assert!(transition(&mut sys).is_none());
544 }
545}