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