Skip to main content

concinnity_core/gfx/anim_graph/
cursor.rs

1// The live position in a compiled graph: the state clock, the in-flight
2// crossfade, and the transition check that advances both.
3
4use super::{CompiledGraph, CompiledState};
5use crate::math::fract;
6
7/// An in-flight crossfade from the previous state. The outgoing state's clock
8/// keeps advancing during the fade so its pose stays live rather than frozen.
9#[derive(Debug, Clone)]
10pub struct StateFade {
11    /// The state being faded out of.
12    pub from_state: usize,
13    /// The outgoing state's clock, which keeps advancing during the fade.
14    pub from_clock: f32,
15    /// Seconds elapsed since the fade started.
16    pub elapsed_secs: f32,
17    /// Duration in seconds.
18    pub duration_secs: f32,
19}
20
21impl StateFade {
22    /// Fade progress in [0, 1]: 0 = all outgoing pose, 1 = all incoming.
23    pub fn progress(&self) -> f32 {
24        (self.elapsed_secs / self.duration_secs.max(1e-6)).clamp(0.0, 1.0)
25    }
26}
27
28/// The live position in a graph: current state, its clock, and any in-flight
29/// crossfade. One per graph target, owned by the client's AnimationSystem.
30///
31/// Clock units depend on the state's play: seconds (scaled by `rate`) for
32/// single clips and non-sync blendspaces, normalized phase (one full pass of
33/// the blend = 1.0) for phase-synced blendspaces, where member clips of
34/// different lengths must share one wrap point.
35#[derive(Debug, Clone)]
36pub struct GraphCursor {
37    /// The state the cursor is in.
38    pub state: usize,
39    /// The state's clock, in the units its play uses.
40    pub clock: f32,
41    /// The in-flight crossfade, when one is running.
42    pub fade: Option<StateFade>,
43}
44
45impl GraphCursor {
46    /// A cursor parked at the graph's initial state.
47    pub fn start(graph: &CompiledGraph) -> Self {
48        Self {
49            state: graph.initial,
50            clock: 0.0,
51            fade: None,
52        }
53    }
54
55    /// Advance clocks by `dt_secs` and take at most one transition. Transition
56    /// checks run against the current state's outgoing list in declaration
57    /// order; the first whose exit-time gate and conditions all pass wins.
58    /// Taking a transition while a fade is in flight replaces the fade: the
59    /// new fade blends from the interrupted fade's *incoming* state only, so a
60    /// rapid double transition can pop the older outgoing pose.
61    pub fn advance(&mut self, graph: &CompiledGraph, params: &[f32], dt_secs: f32) {
62        let dt = dt_secs.max(0.0);
63        let state = &graph.states[self.state];
64        advance_clock(state, params, dt, &mut self.clock);
65        if let Some(fade) = self.fade.as_mut() {
66            fade.elapsed_secs += dt;
67            advance_clock(
68                &graph.states[fade.from_state],
69                params,
70                dt,
71                &mut fade.from_clock,
72            );
73            if fade.elapsed_secs >= fade.duration_secs {
74                self.fade = None;
75            }
76        }
77
78        let normalized = normalized_time(state, self.clock, params);
79        for tr in &state.transitions {
80            if let Some(gate) = tr.exit_time
81                && normalized < gate
82            {
83                continue;
84            }
85            let hold = tr.conditions.iter().any(|c| {
86                let lhs = params.get(c.param).copied().unwrap_or(0.0);
87                !c.op.eval(lhs, c.value)
88            });
89            if hold {
90                continue;
91            }
92            self.fade = (tr.duration_secs > 0.0).then_some(StateFade {
93                from_state: self.state,
94                from_clock: self.clock,
95                elapsed_secs: 0.0,
96                duration_secs: tr.duration_secs,
97            });
98            self.state = tr.to;
99            self.clock = 0.0;
100            break;
101        }
102    }
103}
104
105// Advance one state's clock: seconds for clips and non-sync blends,
106// normalized phase for synced blends (dividing by the blend's current
107// effective duration keeps one wall-clock second worth of playback per
108// second regardless of which members dominate).
109fn advance_clock(state: &CompiledState, params: &[f32], dt: f32, clock: &mut f32) {
110    if state.play.sync() {
111        let weights = state.play.weights(params);
112        let eff = state.play.effective_duration(&weights).max(1e-6);
113        *clock += dt * state.rate / eff;
114    } else {
115        *clock += dt * state.rate;
116    }
117}
118
119/// A state's normalized time in [0, 1]: the fraction of one full pass covered
120/// by the clock. Looping states report the fraction within the current pass,
121/// so an `exit_time` gate re-opens every loop; a non-looping state saturates
122/// at one. Blendspace passes are measured against the weight-averaged member
123/// duration at the current parameters.
124pub fn normalized_time(state: &CompiledState, clock: f32, params: &[f32]) -> f32 {
125    let phase = if state.play.sync() {
126        clock
127    } else {
128        let weights = state.play.weights(params);
129        let eff = state.play.effective_duration(&weights);
130        if eff <= 1e-6 {
131            return 1.0;
132        }
133        clock / eff
134    };
135    if state.looping {
136        fract(phase)
137    } else {
138        phase.clamp(0.0, 1.0)
139    }
140}