Skip to main content

concinnity_core/gfx/
root_motion.rs

1//! Root-motion track: the character-displacement curve stripped out of a
2//! clip's root joint at build time. The pose keeps the root anchored in
3//! place; the runtime samples this track's frame-to-frame delta instead and
4//! feeds it to whatever moves the character (a physics capsule, or the mesh
5//! transform directly). Pure math, unit-tested here.
6
7use alloc::vec::Vec;
8
9use crate::math::vec3::{lerp, sub};
10use crate::math::{floor, rem_euclid};
11
12/// One key of a root-motion curve: the root joint's stripped translation at
13/// `time` seconds from the clip start.
14#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize, serde::Deserialize)]
15pub struct RootKey {
16    /// Seconds from the clip start.
17    pub time: f32,
18    /// The root joint's stripped translation at `time`.
19    pub translation: [f32; 3],
20}
21
22/// The displacement curve of one clip, in the mesh's model space. Keys are in
23/// ascending time order (the build bakes them from the root joint's keyframe
24/// track, so they inherit its ordering).
25#[derive(Debug, Clone, Default)]
26pub struct RootTrack {
27    /// Curve keys, in ascending time order.
28    pub keys: Vec<RootKey>,
29}
30
31impl RootTrack {
32    /// The curve's translation at clip-local time `t`, clamped to the key
33    /// range; between keys the translation lerps.
34    pub fn sample(&self, t: f32) -> [f32; 3] {
35        match self.keys.as_slice() {
36            [] => [0.0; 3],
37            [only] => only.translation,
38            keys => {
39                if t <= keys[0].time {
40                    return keys[0].translation;
41                }
42                let last = keys[keys.len() - 1];
43                if t >= last.time {
44                    return last.translation;
45                }
46                for w in keys.windows(2) {
47                    let (a, b) = (w[0], w[1]);
48                    if t >= a.time && t <= b.time {
49                        let span = (b.time - a.time).max(1e-6);
50                        let f = (t - a.time) / span;
51                        return lerp(a.translation, b.translation, f);
52                    }
53                }
54                last.translation
55            }
56        }
57    }
58
59    /// The displacement covered between two *unwrapped* clip times
60    /// (`t0 <= t1`, in the same seconds the clip clock runs on). A looping
61    /// clip adds one full per-cycle displacement for every wrap crossed, so a
62    /// multi-loop frame (or a long hitch) loses no ground; a non-looping clip
63    /// clamps both ends.
64    pub fn delta(&self, t0: f32, t1: f32, duration: f32, looping: bool) -> [f32; 3] {
65        if !looping || duration <= 1e-6 {
66            return sub(self.sample(t1), self.sample(t0));
67        }
68        let cycles = floor(t1 / duration) - floor(t0 / duration);
69        let per_cycle = sub(self.sample(duration), self.sample(0.0));
70        let within = sub(
71            self.sample(rem_euclid(t1, duration)),
72            self.sample(rem_euclid(t0, duration)),
73        );
74        [
75            within[0] + cycles * per_cycle[0],
76            within[1] + cycles * per_cycle[1],
77            within[2] + cycles * per_cycle[2],
78        ]
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use alloc::vec;
86
87    // 1s clip walking +2 on X per cycle, linear.
88    fn walk_x() -> RootTrack {
89        RootTrack {
90            keys: vec![
91                RootKey {
92                    time: 0.0,
93                    translation: [0.0; 3],
94                },
95                RootKey {
96                    time: 1.0,
97                    translation: [2.0, 0.0, 0.0],
98                },
99            ],
100        }
101    }
102
103    #[test]
104    fn sample_clamps_and_lerps() {
105        let track = walk_x();
106        assert_eq!(track.sample(-1.0), [0.0; 3]);
107        assert_eq!(track.sample(2.0), [2.0, 0.0, 0.0]);
108        assert!((track.sample(0.25)[0] - 0.5).abs() < 1e-6);
109        assert_eq!(RootTrack::default().sample(0.5), [0.0; 3]);
110    }
111
112    #[test]
113    fn delta_within_one_cycle() {
114        let track = walk_x();
115        let d = track.delta(0.25, 0.75, 1.0, true);
116        assert!((d[0] - 1.0).abs() < 1e-6);
117    }
118
119    #[test]
120    fn delta_across_a_wrap_adds_the_cycle_displacement() {
121        let track = walk_x();
122        // 0.75 -> 1.25 covers the wrap: 0.5s of walking = +1.0 X.
123        let d = track.delta(0.75, 1.25, 1.0, true);
124        assert!((d[0] - 1.0).abs() < 1e-5, "{d:?}");
125    }
126
127    #[test]
128    fn delta_across_multiple_wraps_loses_no_ground() {
129        let track = walk_x();
130        // 3.4 cycles = +6.8 X.
131        let d = track.delta(0.1, 3.5, 1.0, true);
132        assert!((d[0] - 6.8).abs() < 1e-5, "{d:?}");
133    }
134
135    #[test]
136    fn non_looping_delta_clamps_at_the_end() {
137        let track = walk_x();
138        let d = track.delta(0.5, 5.0, 1.0, false);
139        assert!((d[0] - 1.0).abs() < 1e-6, "holds the final key: {d:?}");
140    }
141
142    #[test]
143    fn zero_duration_degrades_to_clamped_endpoint_sampling() {
144        let track = walk_x();
145        // A degenerate duration cannot wrap; the delta falls back to plain
146        // clamped endpoint sampling instead of dividing by zero.
147        let d = track.delta(0.0, 1.0, 0.0, true);
148        assert!((d[0] - 2.0).abs() < 1e-6, "{d:?}");
149    }
150}