concinnity_engine/app/anim_runtime.rs
1//! Process-wide command queue for runtime animation control (crossfades,
2//! graph parameter writes, graph state queries). Mirrors the shape of
3//! `crate::debug::runtime_spawn`, but separate so the AnimationSystem can
4//! drain its own commands without contending with GraphicsSystem's decal /
5//! particle queue.
6//!
7//! The debug server (binary-only, off the engine thread) pushes
8//! commands here; the editor's per-frame debug drive drains them via
9//! `AnimationSystem::apply_runtime_commands` every frame -- including while a
10//! menu pauses playback, so a blocked WS client always gets its reply. Each
11//! command carries a reply channel the drain fulfils synchronously.
12
13use std::sync::Mutex;
14
15use crate::ecs::asset_id::AssetId;
16
17/// One queued crossfade request. `target` is the `SkinnedMesh` asset id the
18/// command applies to; `weights` must match the clip count registered for
19/// that target. `duration_secs == 0` snaps to the new weights on the next
20/// frame. Rejected when the target is graph-driven (use `SetParam`).
21#[derive(Debug)]
22pub struct CrossfadeRequest {
23 /// The `SkinnedMesh` the crossfade applies to.
24 pub target: AssetId,
25 /// One weight per registered clip on the target.
26 pub weights: Vec<f32>,
27 /// Duration in seconds.
28 pub duration_secs: f32,
29}
30
31/// One queued graph parameter write. `target` is the `SkinnedMesh` whose
32/// graph declares the parameter; the value lands in the target's `AnimationParams`
33/// component on the next animation step.
34#[derive(Debug)]
35pub struct SetParamRequest {
36 /// The `SkinnedMesh` whose graph declares the parameter.
37 pub target: AssetId,
38 /// The parameter's authored name.
39 pub name: String,
40 /// The value to write.
41 pub value: f32,
42}
43
44/// Snapshot of a graph target's live state, answered synchronously to the
45/// `anim-state` debug command. Parameter values are as of the last completed
46/// animation step (a pending `SetParam` shows up after the next step).
47#[derive(Debug, Clone)]
48pub struct GraphStateReport {
49 /// Name of the state the target is in.
50 pub state: String,
51 /// The state's clock, in seconds.
52 pub clock_secs: f32,
53 /// Name of the state being faded out of, when a fade is in flight.
54 pub fading_from: Option<String>,
55 /// Fade progress in `[0, 1]`, when a fade is in flight.
56 pub fade_progress: Option<f32>,
57 /// One weight per blendspace member (point / grid order); None when the
58 /// active state plays a single clip.
59 pub blend_weights: Option<Vec<f32>>,
60 /// Every graph parameter with its value, as of the last step.
61 pub params: Vec<(String, f32)>,
62}
63
64/// One runtime command pushed onto [`enqueue`] by the debug WS server and
65/// drained by `AnimationSystem::apply_runtime_commands`.
66pub enum AnimCommand {
67 /// Crossfade a target's clip weights.
68 Crossfade {
69 /// The requested crossfade.
70 req: CrossfadeRequest,
71 /// Where the outcome is sent.
72 reply: std::sync::mpsc::SyncSender<Result<(), String>>,
73 },
74 /// Write one graph parameter.
75 SetParam {
76 /// The requested write.
77 req: SetParamRequest,
78 /// Where the outcome is sent.
79 reply: std::sync::mpsc::SyncSender<Result<(), String>>,
80 },
81 /// Report a target's live graph state.
82 QueryState {
83 /// The \`SkinnedMesh\` to report on.
84 target: AssetId,
85 /// Where the report is sent.
86 reply: std::sync::mpsc::SyncSender<Result<GraphStateReport, String>>,
87 },
88}
89
90static QUEUE: Mutex<Vec<AnimCommand>> = Mutex::new(Vec::new());
91
92// Serialises the tests that drive the queue. It is process-wide and `drain`
93// takes all of it, so two tests enqueuing at once would steal each other's
94// commands; every such test holds this for its enqueue + drain.
95#[cfg(test)]
96pub(crate) static TEST_LOCK: Mutex<()> = Mutex::new(());
97
98/// Push a command onto the animation runtime queue. The caller blocks on its
99/// own reply receiver to get the result. A poisoned mutex is recovered and
100/// used regardless (an unrelated panic must not silently drop commands).
101pub fn enqueue(cmd: AnimCommand) {
102 let mut q = match QUEUE.lock() {
103 Ok(g) => g,
104 Err(poisoned) => poisoned.into_inner(),
105 };
106 q.push(cmd);
107}
108
109// Take every queued command. Drained by `AnimationSystem::apply_runtime_commands`,
110// which the `cn debug` drive (`DebugHook::tick`) calls each frame. The
111// returned `Vec` is the live list: the queue is reset to empty.
112pub(crate) fn drain() -> Vec<AnimCommand> {
113 let mut q = match QUEUE.lock() {
114 Ok(g) => g,
115 Err(poisoned) => poisoned.into_inner(),
116 };
117 std::mem::take(&mut *q)
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123
124 #[test]
125 fn enqueue_drain_round_trip() {
126 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
127 let _ = drain();
128 let (tx, _rx) = std::sync::mpsc::sync_channel(1);
129 enqueue(AnimCommand::Crossfade {
130 req: CrossfadeRequest {
131 target: AssetId::default(),
132 weights: vec![1.0, 0.0],
133 duration_secs: 0.5,
134 },
135 reply: tx,
136 });
137 let cmds = drain();
138 assert_eq!(cmds.len(), 1);
139 assert!(drain().is_empty());
140 }
141
142 // A poisoned queue is recovered rather than swallowing commands: an
143 // unrelated panic must not silently break runtime control.
144 #[test]
145 fn a_poisoned_queue_still_enqueues_and_drains() {
146 let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
147 let _ = drain();
148 // Poison the queue's own mutex from a panicking thread.
149 let _ = std::thread::spawn(|| {
150 let _held = QUEUE.lock().unwrap();
151 panic!("poison");
152 })
153 .join();
154 assert!(QUEUE.is_poisoned());
155
156 let (tx, _rx) = std::sync::mpsc::sync_channel(1);
157 enqueue(AnimCommand::QueryState {
158 target: AssetId::default(),
159 reply: tx,
160 });
161 assert_eq!(drain().len(), 1);
162 QUEUE.clear_poison();
163 }
164}