Skip to main content

viewport_lib/runtime/plugins/skeleton_plugin/
clip.rs

1//! Animation clip data model and sampling.
2//!
3//! A [`Sampler`] holds keyframe times paired with values and produces a
4//! [`TrackValue`] at any time `t`. A [`Track`] binds a sampler to one channel
5//! (translation, rotation, or scale) on one joint. An [`AnimationClip`] is a
6//! collection of tracks plus a duration; [`AnimationClip::sample_into`]
7//! applies all tracks to a [`Pose`] at time `t`.
8//!
9//! # Semantics
10//!
11//! `sample_into` only touches joints that appear in the clip's tracks. For each
12//! such joint, the local transform is decomposed into translation/rotation/
13//! scale, the channels named by the clip's tracks are overwritten with sampled
14//! values, untouched channels keep their existing values, and the result is
15//! recomposed. Joints not mentioned by any track are left alone. This matches
16//! the glTF animation model and lets a clip carry only the channels it needs
17//! while a bind pose supplies the rest.
18//!
19//! # Out of scope
20//!
21//! - Cubic-spline interpolation (not yet implemented).
22//! - Per-track weight blending via `BlendNode` (not yet implemented).
23
24use super::skeleton::Pose;
25
26/// Which component of a joint's local transform a track drives.
27#[derive(Copy, Clone, Debug, Eq, PartialEq)]
28pub enum Channel {
29    /// Drives the joint's local translation (Vec3 sampler values).
30    Translation,
31    /// Drives the joint's local rotation (Quat sampler values).
32    Rotation,
33    /// Drives the joint's local scale (Vec3 sampler values).
34    Scale,
35}
36
37/// How a sampler blends between adjacent keyframes.
38#[derive(Copy, Clone, Debug, Eq, PartialEq)]
39pub enum Interpolation {
40    /// Hold the value of the lower keyframe until the next one starts.
41    Step,
42    /// Vec3 channels use componentwise lerp; Quat channels use slerp.
43    Linear,
44}
45
46/// Sampled value at a point in time. Variant must match the parent track's
47/// channel: `Translation`/`Scale` produce `Vec3`, `Rotation` produces `Quat`.
48#[derive(Copy, Clone, Debug)]
49pub enum TrackValue {
50    /// Translation or scale sample.
51    Vec3(glam::Vec3),
52    /// Rotation sample.
53    Quat(glam::Quat),
54}
55
56/// Per-keyframe values for a track. The variant must match the channel of any
57/// track using this sampler.
58#[derive(Clone, Debug)]
59pub enum TrackValues {
60    /// Keyframe values for translation or scale tracks.
61    Vec3(Vec<glam::Vec3>),
62    /// Keyframe values for rotation tracks.
63    Quat(Vec<glam::Quat>),
64}
65
66impl TrackValues {
67    fn len(&self) -> usize {
68        match self {
69            TrackValues::Vec3(v) => v.len(),
70            TrackValues::Quat(v) => v.len(),
71        }
72    }
73}
74
75/// Keyframe times paired with values plus an interpolation mode.
76///
77/// `times` must be non-empty, strictly increasing, and the same length as the
78/// inner `values` vector.
79#[derive(Clone, Debug)]
80pub struct Sampler {
81    /// Interpolation mode between adjacent keyframes.
82    pub interpolation: Interpolation,
83    /// Keyframe times in seconds. Must be non-empty and strictly increasing.
84    pub times: Vec<f32>,
85    /// Keyframe values, same length as `times`.
86    pub values: TrackValues,
87}
88
89impl Sampler {
90    /// Sample at time `t`. Times outside `[times.first(), times.last()]` clamp
91    /// to the nearest endpoint.
92    pub fn sample(&self, t: f32) -> TrackValue {
93        debug_assert!(!self.times.is_empty(), "sampler has no keyframes");
94        debug_assert_eq!(
95            self.times.len(),
96            self.values.len(),
97            "times/values length mismatch"
98        );
99
100        let n = self.times.len();
101        if t <= self.times[0] {
102            return self.value_at(0);
103        }
104        if t >= self.times[n - 1] {
105            return self.value_at(n - 1);
106        }
107
108        // Segment: times[i] <= t < times[i+1].
109        let i = self.times.partition_point(|&x| x <= t).saturating_sub(1);
110        let j = i + 1;
111        match self.interpolation {
112            Interpolation::Step => self.value_at(i),
113            Interpolation::Linear => {
114                let t0 = self.times[i];
115                let t1 = self.times[j];
116                let alpha = (t - t0) / (t1 - t0);
117                self.lerp(i, j, alpha)
118            }
119        }
120    }
121
122    fn value_at(&self, i: usize) -> TrackValue {
123        match &self.values {
124            TrackValues::Vec3(v) => TrackValue::Vec3(v[i]),
125            TrackValues::Quat(v) => TrackValue::Quat(v[i]),
126        }
127    }
128
129    fn lerp(&self, a: usize, b: usize, alpha: f32) -> TrackValue {
130        match &self.values {
131            TrackValues::Vec3(v) => TrackValue::Vec3(v[a].lerp(v[b], alpha)),
132            TrackValues::Quat(v) => TrackValue::Quat(v[a].slerp(v[b], alpha)),
133        }
134    }
135}
136
137/// One animation track: a sampler driving one channel on one joint.
138#[derive(Clone, Debug)]
139pub struct Track {
140    /// Index into the target skeleton's joint list.
141    pub joint: usize,
142    /// Which component of the joint's local transform this track drives.
143    pub channel: Channel,
144    /// Keyframe sampler producing values for this channel.
145    pub sampler: Sampler,
146}
147
148/// A collection of tracks that together animate one or more joints over time.
149#[derive(Clone, Debug)]
150pub struct AnimationClip {
151    /// Length of the clip in seconds. Used by the player to loop the playhead.
152    pub duration: f32,
153    /// Per-channel tracks. Multiple tracks may target the same joint.
154    pub tracks: Vec<Track>,
155}
156
157impl AnimationClip {
158    /// Apply all tracks to `pose` at time `t`.
159    ///
160    /// For each joint mentioned by any track, the joint's existing local
161    /// transform is decomposed into scale/rotation/translation; sampled values
162    /// from this clip's tracks overwrite the matching channels; the untouched
163    /// channels keep their existing values; and the joint is recomposed as
164    /// `T * R * S`.
165    ///
166    /// Joints not mentioned by any track are left unchanged. Tracks naming a
167    /// joint index outside `pose.local_transforms` are silently skipped.
168    pub fn sample_into(&self, t: f32, pose: &mut Pose) {
169        // Sample once per track, grouped by joint. Three Option slots per joint
170        // hold an optional sampled value for translation, rotation, scale.
171        let mut affected: Vec<(usize, [Option<TrackValue>; 3])> = Vec::new();
172
173        for track in &self.tracks {
174            if track.joint >= pose.local_transforms.len() {
175                continue;
176            }
177            let v = track.sampler.sample(t);
178            let slot = channel_slot(track.channel);
179
180            if let Some((_, channels)) = affected.iter_mut().find(|(j, _)| *j == track.joint) {
181                channels[slot] = Some(v);
182            } else {
183                let mut channels = [None, None, None];
184                channels[slot] = Some(v);
185                affected.push((track.joint, channels));
186            }
187        }
188
189        for (joint_idx, channels) in affected {
190            let local = &mut pose.local_transforms[joint_idx];
191            let (s_old, r_old, t_old) = local.to_scale_rotation_translation();
192            let t_new = match channels[0] {
193                Some(TrackValue::Vec3(v)) => v,
194                _ => t_old,
195            };
196            let r_new = match channels[1] {
197                Some(TrackValue::Quat(q)) => q,
198                _ => r_old,
199            };
200            let s_new = match channels[2] {
201                Some(TrackValue::Vec3(v)) => v,
202                _ => s_old,
203            };
204            *local = glam::Affine3A::from_scale_rotation_translation(s_new, r_new, t_new);
205        }
206    }
207}
208
209fn channel_slot(c: Channel) -> usize {
210    match c {
211        Channel::Translation => 0,
212        Channel::Rotation => 1,
213        Channel::Scale => 2,
214    }
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use glam::{Quat, Vec3};
221
222    fn approx_eq_vec3(a: Vec3, b: Vec3, eps: f32) -> bool {
223        (a - b).length() < eps
224    }
225
226    fn approx_eq_quat(a: Quat, b: Quat, eps: f32) -> bool {
227        // Quaternions are equivalent up to sign.
228        (a.dot(b).abs() - 1.0).abs() < eps
229    }
230
231    #[test]
232    fn linear_sampler_lerps_vec3_between_keyframes() {
233        let s = Sampler {
234            interpolation: Interpolation::Linear,
235            times: vec![0.0, 1.0, 2.0],
236            values: TrackValues::Vec3(vec![Vec3::ZERO, Vec3::new(2.0, 0.0, 0.0), Vec3::ZERO]),
237        };
238        match s.sample(0.5) {
239            TrackValue::Vec3(v) => assert!(approx_eq_vec3(v, Vec3::new(1.0, 0.0, 0.0), 1e-5)),
240            _ => panic!("wrong variant"),
241        }
242        match s.sample(1.75) {
243            TrackValue::Vec3(v) => assert!(approx_eq_vec3(v, Vec3::new(0.5, 0.0, 0.0), 1e-5)),
244            _ => panic!("wrong variant"),
245        }
246    }
247
248    #[test]
249    fn step_sampler_holds_lower_keyframe() {
250        let s = Sampler {
251            interpolation: Interpolation::Step,
252            times: vec![0.0, 1.0, 2.0],
253            values: TrackValues::Vec3(vec![Vec3::ZERO, Vec3::X, Vec3::Y]),
254        };
255        // Anywhere in [0, 1) yields the value at index 0.
256        match s.sample(0.999) {
257            TrackValue::Vec3(v) => assert_eq!(v, Vec3::ZERO),
258            _ => panic!(),
259        }
260        // Exactly at a keyframe yields that keyframe.
261        match s.sample(1.0) {
262            TrackValue::Vec3(v) => assert_eq!(v, Vec3::X),
263            _ => panic!(),
264        }
265        match s.sample(1.5) {
266            TrackValue::Vec3(v) => assert_eq!(v, Vec3::X),
267            _ => panic!(),
268        }
269    }
270
271    #[test]
272    fn sampler_clamps_outside_range() {
273        let s = Sampler {
274            interpolation: Interpolation::Linear,
275            times: vec![0.0, 1.0],
276            values: TrackValues::Vec3(vec![Vec3::ZERO, Vec3::X]),
277        };
278        match s.sample(-5.0) {
279            TrackValue::Vec3(v) => assert_eq!(v, Vec3::ZERO),
280            _ => panic!(),
281        }
282        match s.sample(100.0) {
283            TrackValue::Vec3(v) => assert_eq!(v, Vec3::X),
284            _ => panic!(),
285        }
286    }
287
288    #[test]
289    fn quat_sampler_slerps_between_keyframes() {
290        let s = Sampler {
291            interpolation: Interpolation::Linear,
292            times: vec![0.0, 1.0],
293            values: TrackValues::Quat(vec![
294                Quat::IDENTITY,
295                Quat::from_rotation_x(std::f32::consts::FRAC_PI_2),
296            ]),
297        };
298        match s.sample(0.5) {
299            TrackValue::Quat(q) => {
300                let expected = Quat::from_rotation_x(std::f32::consts::FRAC_PI_4);
301                assert!(approx_eq_quat(q, expected, 1e-4), "got {q:?}");
302            }
303            _ => panic!(),
304        }
305    }
306
307    #[test]
308    fn sample_into_overwrites_only_animated_channels() {
309        // Bind pose: joint 1 sits at (0, 0, 2) with no rotation.
310        let mut pose = Pose::identity(2);
311        pose.local_transforms[1] = glam::Affine3A::from_translation(Vec3::new(0.0, 0.0, 2.0));
312
313        // Clip: rotate joint 1 only. Translation should be preserved from the
314        // bind pose because the clip has no translation track.
315        let clip = AnimationClip {
316            duration: 1.0,
317            tracks: vec![Track {
318                joint: 1,
319                channel: Channel::Rotation,
320                sampler: Sampler {
321                    interpolation: Interpolation::Linear,
322                    times: vec![0.0, 1.0],
323                    values: TrackValues::Quat(vec![
324                        Quat::IDENTITY,
325                        Quat::from_rotation_x(std::f32::consts::FRAC_PI_2),
326                    ]),
327                },
328            }],
329        };
330
331        clip.sample_into(0.5, &mut pose);
332
333        let (scale, rot, trans) = pose.local_transforms[1].to_scale_rotation_translation();
334        // Translation preserved from bind pose.
335        assert!(approx_eq_vec3(trans, Vec3::new(0.0, 0.0, 2.0), 1e-4));
336        // Rotation taken from the clip at t=0.5 (45 deg around X).
337        let expected_rot = Quat::from_rotation_x(std::f32::consts::FRAC_PI_4);
338        assert!(approx_eq_quat(rot, expected_rot, 1e-4));
339        // Scale untouched -> still 1.
340        assert!(approx_eq_vec3(scale, Vec3::ONE, 1e-4));
341    }
342
343    #[test]
344    fn sample_into_two_tracks_compose_correctly() {
345        // Joint 0 bind pose is identity. Animate both translation and scale.
346        let mut pose = Pose::identity(1);
347        let clip = AnimationClip {
348            duration: 2.0,
349            tracks: vec![
350                Track {
351                    joint: 0,
352                    channel: Channel::Translation,
353                    sampler: Sampler {
354                        interpolation: Interpolation::Linear,
355                        times: vec![0.0, 2.0],
356                        values: TrackValues::Vec3(vec![Vec3::ZERO, Vec3::new(4.0, 0.0, 0.0)]),
357                    },
358                },
359                Track {
360                    joint: 0,
361                    channel: Channel::Scale,
362                    sampler: Sampler {
363                        interpolation: Interpolation::Step,
364                        times: vec![0.0, 1.0],
365                        values: TrackValues::Vec3(vec![Vec3::ONE, Vec3::splat(2.0)]),
366                    },
367                },
368            ],
369        };
370
371        clip.sample_into(1.5, &mut pose);
372
373        let (scale, _rot, trans) = pose.local_transforms[0].to_scale_rotation_translation();
374        assert!(approx_eq_vec3(trans, Vec3::new(3.0, 0.0, 0.0), 1e-4));
375        assert!(approx_eq_vec3(scale, Vec3::splat(2.0), 1e-4));
376    }
377
378    #[test]
379    fn out_of_range_joint_index_is_skipped() {
380        let mut pose = Pose::identity(2);
381        let clip = AnimationClip {
382            duration: 1.0,
383            tracks: vec![Track {
384                joint: 99,
385                channel: Channel::Translation,
386                sampler: Sampler {
387                    interpolation: Interpolation::Step,
388                    times: vec![0.0],
389                    values: TrackValues::Vec3(vec![Vec3::new(1.0, 2.0, 3.0)]),
390                },
391            }],
392        };
393        clip.sample_into(0.5, &mut pose);
394        // Pose untouched.
395        assert_eq!(pose.local_transforms[0], glam::Affine3A::IDENTITY);
396        assert_eq!(pose.local_transforms[1], glam::Affine3A::IDENTITY);
397    }
398}