Skip to main content

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 WebSocket 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}