Skip to main content

concinnity_engine/gfx/animation/
commands.rs

1// src/gfx/animation/commands.rs
2//
3// Runtime debug-command drain: `anim-crossfade` (flat buckets), `anim-param`
4// and `anim-state` (graph buckets). Commands arrive on the process-wide
5// `crate::app::anim_runtime` queue from the debug WS server and each carries
6// a reply channel answered synchronously here. The drain is driven from the
7// editor's per-frame `DebugHook::tick` (not from `step`) so a WS client
8// blocked on a reply is never starved while a menu pauses playback.
9
10use 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    /// Drain pending runtime commands against the system's own clock. Uses the
20    /// same `start` / elapsed bookkeeping `step` uses, so the binary-only
21    /// `DebugHook::tick` drive can apply commands from outside the per-system
22    /// step. The library never calls this; `step` runs after the hook on the
23    /// same frame, so the `start` anchor set here is shared.
24    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    // Drain pending runtime commands and apply them. Commands run in queue
32    // order, so a later command for the same target supersedes an earlier
33    // one; a command that does not fit its target's mode fails without
34    // touching anything.
35    fn drain_runtime_commands(&mut self, now_secs: f32) {
36        // Commands address a mesh by its interned NAME id (the WS server
37        // resolves the typed name against the interner); the buckets are keyed
38        // by handle, so translate through the name index captured at init.
39        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    // Set up a weight ramp on a flat bucket from its current weights to
63    // `weights` over `duration_secs`. `pub(super)` so tests can drive it
64    // without the process-wide command queue.
65    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    // Queue a parameter write on a graph bucket; it lands in the target's
101    // `AnimationParams` component at the top of the next animation step.
102    // `pub(super)` for queue-free tests, like `apply_crossfade`.
103    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    // Snapshot a graph bucket's live state for the `anim-state` command.
120    // Parameter values are as of the last completed step. Also serves tests.
121    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            // Only meaningful for blendspace states; a single clip is
136            // always [1.0], reported as None to keep the JSON quiet.
137            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    // The interned mesh name a command addresses, deliberately different from
183    // the handle so the index translation is observable.
184    const NAME: AssetId = AssetId(77);
185
186    // A bare clip; the command surface never samples one.
187    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    // A system holding one flat bucket of `clips` clips, each at full weight.
202    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    // An idle/run graph on TARGET crossfading over `fade_secs` when `speed`
218    // passes 0.5. Every state resolves onto the bucket's single clip: the
219    // command surface reports the machine, it never samples a pose.
220    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    // Reach into a flat bucket's in-flight ramp.
259    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    // Drive a graph bucket's cursor directly, so fades are driven by an
267    // explicit dt rather than the wall clock.
268    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, &params, dt);
278    }
279
280    // The command queue is process-wide: `drain` takes everything on it, so the
281    // tests that drive it serialise on a shared lock rather than stealing each
282    // other's commands. Any leftovers from a panicking earlier test are not ours.
283    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    // A crossfade on a target with no clips registered names the command that
292    // failed rather than silently doing nothing.
293    #[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    // A weight vector that does not match the bucket's clip count is refused,
304    // and nothing is mutated: a half-applied blend is impossible.
305    #[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    // An accepted crossfade ramps from the bucket's live weights to the
317    // requested ones, anchored at the caller's clock.
318    #[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    // A negative duration clamps to zero (an immediate snap) rather than
331    // producing a ramp that never finishes.
332    #[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    // A later crossfade for the same target supersedes the one in flight.
340    #[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    // Both graph commands report an unregistered target by name of the command
351    // that asked, so a typo'd mesh is distinguishable from a mode mismatch.
352    #[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    // A parameter the graph does not declare is refused and queues nothing.
364    #[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    // A queued write is held against the declared parameter's index until the
374    // next step flushes it into the component.
375    #[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    // A parked graph reports its state and clock with no fade in flight.
390    #[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    // Mid-transition the report names the outgoing state and how far the
405    // crossfade has run.
406    #[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        // The pending write only lands on a step, so seed the snapshot the
411        // cursor reads directly.
412        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        // One advance takes the transition and installs the fade at zero; the
420        // next runs it a fifth of the way through.
421        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        // The clock reports seconds into the incoming state, not the fade.
430        assert!((report.clock_secs - 0.1).abs() < 1e-4, "{report:?}");
431    }
432
433    // A fade that has run its length is dropped, so the report goes quiet again.
434    #[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    // Commands address a mesh by its interned NAME id; the drain translates it
453    // through the index captured at init and applies the crossfade against the
454    // clock it was handed, answering the caller's reply channel.
455    #[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    // A parameter write and a state query take the same name translation, and
478    // each reply is answered synchronously by the drain.
479    #[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    // A command for a mesh the index does not know still gets its reply: the
505    // failure is reported, never dropped on the floor.
506    #[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    // The hook drive anchors the system's clock on its first call and answers
520    // whatever is queued, so a WS client blocked on a reply is never starved by
521    // a paused world.
522    #[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    // An empty queue is a no-op the drive can call every frame.
538    #[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}