Skip to main content

pebble/wgpu/
animation.rs

1//! Plain CPU data for keyframe animation — no [`Asset`](crate::assets::upload::Asset)/
2//! `Handle`/GPU upload involved, same rationale as [`skeleton`](super::skeleton):
3//! sampling a clip is pure interpolation math, nothing GPU-specific about it.
4
5use super::skeleton::{Skeleton, Transform};
6
7/// glTF 2.0 sampler interpolation. `CubicSpline` is intentionally absent as
8/// a variant — [`gltf_loader::load_gltf`](super::gltf_loader::load_gltf)
9/// hard-errors on it rather than silently mishandling its 3-value-per-keyframe
10/// (in-tangent/value/out-tangent) packing, which would otherwise be
11/// misread as plain values.
12#[derive(Copy, Clone, Debug, PartialEq, Eq)]
13pub enum Interpolation {
14    Linear,
15    Step,
16}
17
18/// One sample of an animated property at a point in time.
19#[derive(Copy, Clone, Debug)]
20pub struct Keyframe<T> {
21    pub time: f32,
22    pub value: T,
23}
24
25/// One joint's animated properties. Any of the three tracks may be empty —
26/// glTF allows animating only some of a joint's translation/rotation/scale;
27/// [`AnimationClip::sample`] falls back to the joint's bind pose for
28/// whichever are unanimated. Keyframes within each track must be in
29/// non-decreasing `time` order (glTF's own accessors are spec-guaranteed to
30/// already be).
31pub struct JointTrack {
32    /// Index into the [`Skeleton`] this track animates.
33    pub joint_index: usize,
34    pub translation: Vec<Keyframe<glam::Vec3>>,
35    pub translation_interpolation: Interpolation,
36    pub rotation: Vec<Keyframe<glam::Quat>>,
37    pub rotation_interpolation: Interpolation,
38    pub scale: Vec<Keyframe<glam::Vec3>>,
39    pub scale_interpolation: Interpolation,
40}
41
42/// A single animation — a name, a duration, and per-joint keyframe tracks.
43/// Blending multiple clips (crossfade, additive layering, ...) is entirely
44/// up to you: sample each clip separately and combine the resulting
45/// `Transform`s yourself — this type only ever samples one clip at a time.
46pub struct AnimationClip {
47    pub name: String,
48    pub duration: f32,
49    tracks: Vec<JointTrack>,
50}
51
52impl AnimationClip {
53    /// `duration` is computed as the latest keyframe time across every
54    /// track/property — not passed in, so it can never drift out of sync
55    /// with the actual keyframe data.
56    pub fn new(name: String, tracks: Vec<JointTrack>) -> Self {
57        let duration = tracks
58            .iter()
59            .flat_map(|t| {
60                t.translation
61                    .iter()
62                    .map(|k| k.time)
63                    .chain(t.rotation.iter().map(|k| k.time))
64                    .chain(t.scale.iter().map(|k| k.time))
65            })
66            .fold(0.0f32, f32::max);
67        Self { name, duration, tracks }
68    }
69
70    /// Samples every joint's local pose at `time`, **clamped** to
71    /// `[0, duration]` — not wrapped. To loop, pass
72    /// `time.rem_euclid(clip.duration)` yourself before calling. `skeleton`
73    /// supplies the bind-pose fallback for any joint this clip has no track
74    /// for, and for whichever of translation/rotation/scale a joint's track
75    /// leaves unspecified. Output is indexed identically to `skeleton` —
76    /// feed straight into [`Skeleton::world_matrices`]/[`skinning_matrices`](Skeleton::skinning_matrices).
77    pub fn sample(&self, time: f32, skeleton: &Skeleton) -> Vec<Transform> {
78        let time = time.clamp(0.0, self.duration);
79        (0..skeleton.joint_count())
80            .map(|i| {
81                let bind = skeleton.joint(i).local_bind_transform;
82                match self.tracks.iter().find(|t| t.joint_index == i) {
83                    Some(track) => Transform {
84                        translation: sample_vec3(&track.translation, track.translation_interpolation, time)
85                            .unwrap_or(bind.translation),
86                        rotation: sample_quat(&track.rotation, track.rotation_interpolation, time)
87                            .unwrap_or(bind.rotation),
88                        scale: sample_vec3(&track.scale, track.scale_interpolation, time).unwrap_or(bind.scale),
89                    },
90                    None => bind,
91                }
92            })
93            .collect()
94    }
95}
96
97/// Where `time` falls relative to a (non-decreasing, by construction)
98/// keyframe track.
99enum Bracket<'a, T> {
100    /// No keyframes at all — the property isn't animated.
101    Empty,
102    /// `time` is at or beyond one end of the track (or the track has only
103    /// one keyframe) — clamp to that single value.
104    Single(&'a T),
105    /// `time` falls strictly between two keyframes; `t` is the normalized
106    /// (0..=1) position between them.
107    Between { left: &'a T, right: &'a T, t: f32 },
108}
109
110fn bracket<T>(keyframes: &[Keyframe<T>], time: f32) -> Bracket<'_, T> {
111    if keyframes.is_empty() {
112        return Bracket::Empty;
113    }
114    if keyframes.len() == 1 || time <= keyframes[0].time {
115        return Bracket::Single(&keyframes[0].value);
116    }
117    let last = keyframes.len() - 1;
118    if time >= keyframes[last].time {
119        return Bracket::Single(&keyframes[last].value);
120    }
121
122    // First index whose time is > `time` is always >= 1 here (time is
123    // already known to be > keyframes[0].time from the checks above).
124    let right_index = keyframes.partition_point(|k| k.time <= time);
125    let left = &keyframes[right_index - 1];
126    let right = &keyframes[right_index];
127    let span = right.time - left.time;
128    let t = if span > 0.0 { (time - left.time) / span } else { 0.0 };
129    Bracket::Between { left: &left.value, right: &right.value, t }
130}
131
132fn sample_vec3(keyframes: &[Keyframe<glam::Vec3>], interpolation: Interpolation, time: f32) -> Option<glam::Vec3> {
133    Some(match bracket(keyframes, time) {
134        Bracket::Empty => return None,
135        Bracket::Single(v) => *v,
136        Bracket::Between { left, right, t } => match interpolation {
137            Interpolation::Step => *left,
138            Interpolation::Linear => left.lerp(*right, t),
139        },
140    })
141}
142
143fn sample_quat(keyframes: &[Keyframe<glam::Quat>], interpolation: Interpolation, time: f32) -> Option<glam::Quat> {
144    Some(match bracket(keyframes, time) {
145        Bracket::Empty => return None,
146        Bracket::Single(v) => *v,
147        Bracket::Between { left, right, t } => match interpolation {
148            Interpolation::Step => *left,
149            Interpolation::Linear => left.slerp(*right, t),
150        },
151    })
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use super::super::skeleton::Joint;
158
159    fn skeleton_with_one_joint() -> Skeleton {
160        Skeleton::new(vec![Joint {
161            name: "root".to_string(),
162            parent: None,
163            inverse_bind_matrix: glam::Mat4::IDENTITY,
164            local_bind_transform: Transform {
165                translation: glam::Vec3::new(9.0, 9.0, 9.0),
166                ..Transform::IDENTITY
167            },
168        }])
169    }
170
171    fn track_with_translation(keyframes: Vec<Keyframe<glam::Vec3>>, interpolation: Interpolation) -> JointTrack {
172        JointTrack {
173            joint_index: 0,
174            translation: keyframes,
175            translation_interpolation: interpolation,
176            rotation: Vec::new(),
177            rotation_interpolation: Interpolation::Linear,
178            scale: Vec::new(),
179            scale_interpolation: Interpolation::Linear,
180        }
181    }
182
183    #[test]
184    fn time_before_the_first_keyframe_clamps_to_it() {
185        let skeleton = skeleton_with_one_joint();
186        let track = track_with_translation(
187            vec![
188                Keyframe { time: 1.0, value: glam::Vec3::new(1.0, 0.0, 0.0) },
189                Keyframe { time: 2.0, value: glam::Vec3::new(2.0, 0.0, 0.0) },
190            ],
191            Interpolation::Linear,
192        );
193        let clip = AnimationClip::new("clip".to_string(), vec![track]);
194
195        let poses = clip.sample(-5.0, &skeleton);
196        assert_eq!(poses[0].translation, glam::Vec3::new(1.0, 0.0, 0.0));
197    }
198
199    #[test]
200    fn time_after_the_last_keyframe_clamps_to_it() {
201        let skeleton = skeleton_with_one_joint();
202        let track = track_with_translation(
203            vec![
204                Keyframe { time: 1.0, value: glam::Vec3::new(1.0, 0.0, 0.0) },
205                Keyframe { time: 2.0, value: glam::Vec3::new(2.0, 0.0, 0.0) },
206            ],
207            Interpolation::Linear,
208        );
209        let clip = AnimationClip::new("clip".to_string(), vec![track]);
210
211        let poses = clip.sample(100.0, &skeleton);
212        assert_eq!(poses[0].translation, glam::Vec3::new(2.0, 0.0, 0.0));
213    }
214
215    #[test]
216    fn exactly_on_a_keyframe_returns_the_exact_value_no_drift() {
217        let skeleton = skeleton_with_one_joint();
218        let track = track_with_translation(
219            vec![
220                Keyframe { time: 0.0, value: glam::Vec3::new(1.0, 0.0, 0.0) },
221                Keyframe { time: 1.0, value: glam::Vec3::new(2.0, 0.0, 0.0) },
222                Keyframe { time: 2.0, value: glam::Vec3::new(3.0, 0.0, 0.0) },
223            ],
224            Interpolation::Linear,
225        );
226        let clip = AnimationClip::new("clip".to_string(), vec![track]);
227
228        let poses = clip.sample(1.0, &skeleton);
229        assert_eq!(poses[0].translation, glam::Vec3::new(2.0, 0.0, 0.0));
230    }
231
232    #[test]
233    fn midpoint_lerps_linearly() {
234        let skeleton = skeleton_with_one_joint();
235        let track = track_with_translation(
236            vec![
237                Keyframe { time: 0.0, value: glam::Vec3::new(0.0, 0.0, 0.0) },
238                Keyframe { time: 2.0, value: glam::Vec3::new(10.0, 0.0, 0.0) },
239            ],
240            Interpolation::Linear,
241        );
242        let clip = AnimationClip::new("clip".to_string(), vec![track]);
243
244        let poses = clip.sample(1.0, &skeleton);
245        assert_eq!(poses[0].translation, glam::Vec3::new(5.0, 0.0, 0.0));
246    }
247
248    #[test]
249    fn step_interpolation_holds_the_left_keyframe() {
250        let skeleton = skeleton_with_one_joint();
251        let track = track_with_translation(
252            vec![
253                Keyframe { time: 0.0, value: glam::Vec3::new(0.0, 0.0, 0.0) },
254                Keyframe { time: 2.0, value: glam::Vec3::new(10.0, 0.0, 0.0) },
255            ],
256            Interpolation::Step,
257        );
258        let clip = AnimationClip::new("clip".to_string(), vec![track]);
259
260        let poses = clip.sample(1.9, &skeleton);
261        assert_eq!(poses[0].translation, glam::Vec3::new(0.0, 0.0, 0.0));
262    }
263
264    #[test]
265    fn a_partially_animated_joint_falls_back_to_bind_pose_for_untracked_properties() {
266        let skeleton = skeleton_with_one_joint();
267        // Only translation is tracked — rotation/scale must fall back to bind pose.
268        let track = track_with_translation(
269            vec![Keyframe { time: 0.0, value: glam::Vec3::new(1.0, 2.0, 3.0) }],
270            Interpolation::Linear,
271        );
272        let clip = AnimationClip::new("clip".to_string(), vec![track]);
273
274        let poses = clip.sample(0.0, &skeleton);
275        assert_eq!(poses[0].translation, glam::Vec3::new(1.0, 2.0, 3.0));
276        assert_eq!(poses[0].rotation, glam::Quat::IDENTITY);
277        assert_eq!(poses[0].scale, glam::Vec3::ONE);
278    }
279
280    #[test]
281    fn a_joint_with_no_track_at_all_is_fully_bind_pose() {
282        let skeleton = skeleton_with_one_joint();
283        let clip = AnimationClip::new("clip".to_string(), vec![]);
284
285        let poses = clip.sample(0.0, &skeleton);
286        assert_eq!(poses[0], Transform { translation: glam::Vec3::new(9.0, 9.0, 9.0), ..Transform::IDENTITY });
287    }
288
289    #[test]
290    fn duration_is_the_max_keyframe_time_across_every_track_and_property() {
291        let track = JointTrack {
292            joint_index: 0,
293            translation: vec![Keyframe { time: 1.0, value: glam::Vec3::ZERO }],
294            translation_interpolation: Interpolation::Linear,
295            rotation: vec![Keyframe { time: 5.0, value: glam::Quat::IDENTITY }],
296            rotation_interpolation: Interpolation::Linear,
297            scale: vec![Keyframe { time: 3.0, value: glam::Vec3::ONE }],
298            scale_interpolation: Interpolation::Linear,
299        };
300        let clip = AnimationClip::new("clip".to_string(), vec![track]);
301        assert_eq!(clip.duration, 5.0);
302    }
303}