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}