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. Phase 3 (glTF import) will add it.
22//! - Per-track weight blending. Phase 7 (`BlendNode`) will add it.
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!(self.times.len(), self.values.len(), "times/values length mismatch");
95
96        let n = self.times.len();
97        if t <= self.times[0] {
98            return self.value_at(0);
99        }
100        if t >= self.times[n - 1] {
101            return self.value_at(n - 1);
102        }
103
104        // Segment: times[i] <= t < times[i+1].
105        let i = self.times.partition_point(|&x| x <= t).saturating_sub(1);
106        let j = i + 1;
107        match self.interpolation {
108            Interpolation::Step => self.value_at(i),
109            Interpolation::Linear => {
110                let t0 = self.times[i];
111                let t1 = self.times[j];
112                let alpha = (t - t0) / (t1 - t0);
113                self.lerp(i, j, alpha)
114            }
115        }
116    }
117
118    fn value_at(&self, i: usize) -> TrackValue {
119        match &self.values {
120            TrackValues::Vec3(v) => TrackValue::Vec3(v[i]),
121            TrackValues::Quat(v) => TrackValue::Quat(v[i]),
122        }
123    }
124
125    fn lerp(&self, a: usize, b: usize, alpha: f32) -> TrackValue {
126        match &self.values {
127            TrackValues::Vec3(v) => TrackValue::Vec3(v[a].lerp(v[b], alpha)),
128            TrackValues::Quat(v) => TrackValue::Quat(v[a].slerp(v[b], alpha)),
129        }
130    }
131}
132
133/// One animation track: a sampler driving one channel on one joint.
134#[derive(Clone, Debug)]
135pub struct Track {
136    /// Index into the target skeleton's joint list.
137    pub joint: usize,
138    /// Which component of the joint's local transform this track drives.
139    pub channel: Channel,
140    /// Keyframe sampler producing values for this channel.
141    pub sampler: Sampler,
142}
143
144/// A collection of tracks that together animate one or more joints over time.
145#[derive(Clone, Debug)]
146pub struct AnimationClip {
147    /// Length of the clip in seconds. Used by the player to loop the playhead.
148    pub duration: f32,
149    /// Per-channel tracks. Multiple tracks may target the same joint.
150    pub tracks: Vec<Track>,
151}
152
153impl AnimationClip {
154    /// Apply all tracks to `pose` at time `t`.
155    ///
156    /// For each joint mentioned by any track, the joint's existing local
157    /// transform is decomposed into scale/rotation/translation; sampled values
158    /// from this clip's tracks overwrite the matching channels; the untouched
159    /// channels keep their existing values; and the joint is recomposed as
160    /// `T * R * S`.
161    ///
162    /// Joints not mentioned by any track are left unchanged. Tracks naming a
163    /// joint index outside `pose.local_transforms` are silently skipped.
164    pub fn sample_into(&self, t: f32, pose: &mut Pose) {
165        // Sample once per track, grouped by joint. Three Option slots per joint
166        // hold an optional sampled value for translation, rotation, scale.
167        let mut affected: Vec<(usize, [Option<TrackValue>; 3])> = Vec::new();
168
169        for track in &self.tracks {
170            if track.joint >= pose.local_transforms.len() {
171                continue;
172            }
173            let v = track.sampler.sample(t);
174            let slot = channel_slot(track.channel);
175
176            if let Some((_, channels)) = affected.iter_mut().find(|(j, _)| *j == track.joint) {
177                channels[slot] = Some(v);
178            } else {
179                let mut channels = [None, None, None];
180                channels[slot] = Some(v);
181                affected.push((track.joint, channels));
182            }
183        }
184
185        for (joint_idx, channels) in affected {
186            let local = &mut pose.local_transforms[joint_idx];
187            let (s_old, r_old, t_old) = local.to_scale_rotation_translation();
188            let t_new = match channels[0] {
189                Some(TrackValue::Vec3(v)) => v,
190                _ => t_old,
191            };
192            let r_new = match channels[1] {
193                Some(TrackValue::Quat(q)) => q,
194                _ => r_old,
195            };
196            let s_new = match channels[2] {
197                Some(TrackValue::Vec3(v)) => v,
198                _ => s_old,
199            };
200            *local = glam::Affine3A::from_scale_rotation_translation(s_new, r_new, t_new);
201        }
202    }
203}
204
205fn channel_slot(c: Channel) -> usize {
206    match c {
207        Channel::Translation => 0,
208        Channel::Rotation => 1,
209        Channel::Scale => 2,
210    }
211}
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216    use glam::{Quat, Vec3};
217
218    fn approx_eq_vec3(a: Vec3, b: Vec3, eps: f32) -> bool {
219        (a - b).length() < eps
220    }
221
222    fn approx_eq_quat(a: Quat, b: Quat, eps: f32) -> bool {
223        // Quaternions are equivalent up to sign.
224        (a.dot(b).abs() - 1.0).abs() < eps
225    }
226
227    #[test]
228    fn linear_sampler_lerps_vec3_between_keyframes() {
229        let s = Sampler {
230            interpolation: Interpolation::Linear,
231            times: vec![0.0, 1.0, 2.0],
232            values: TrackValues::Vec3(vec![Vec3::ZERO, Vec3::new(2.0, 0.0, 0.0), Vec3::ZERO]),
233        };
234        match s.sample(0.5) {
235            TrackValue::Vec3(v) => assert!(approx_eq_vec3(v, Vec3::new(1.0, 0.0, 0.0), 1e-5)),
236            _ => panic!("wrong variant"),
237        }
238        match s.sample(1.75) {
239            TrackValue::Vec3(v) => assert!(approx_eq_vec3(v, Vec3::new(0.5, 0.0, 0.0), 1e-5)),
240            _ => panic!("wrong variant"),
241        }
242    }
243
244    #[test]
245    fn step_sampler_holds_lower_keyframe() {
246        let s = Sampler {
247            interpolation: Interpolation::Step,
248            times: vec![0.0, 1.0, 2.0],
249            values: TrackValues::Vec3(vec![Vec3::ZERO, Vec3::X, Vec3::Y]),
250        };
251        // Anywhere in [0, 1) yields the value at index 0.
252        match s.sample(0.999) {
253            TrackValue::Vec3(v) => assert_eq!(v, Vec3::ZERO),
254            _ => panic!(),
255        }
256        // Exactly at a keyframe yields that keyframe.
257        match s.sample(1.0) {
258            TrackValue::Vec3(v) => assert_eq!(v, Vec3::X),
259            _ => panic!(),
260        }
261        match s.sample(1.5) {
262            TrackValue::Vec3(v) => assert_eq!(v, Vec3::X),
263            _ => panic!(),
264        }
265    }
266
267    #[test]
268    fn sampler_clamps_outside_range() {
269        let s = Sampler {
270            interpolation: Interpolation::Linear,
271            times: vec![0.0, 1.0],
272            values: TrackValues::Vec3(vec![Vec3::ZERO, Vec3::X]),
273        };
274        match s.sample(-5.0) {
275            TrackValue::Vec3(v) => assert_eq!(v, Vec3::ZERO),
276            _ => panic!(),
277        }
278        match s.sample(100.0) {
279            TrackValue::Vec3(v) => assert_eq!(v, Vec3::X),
280            _ => panic!(),
281        }
282    }
283
284    #[test]
285    fn quat_sampler_slerps_between_keyframes() {
286        let s = Sampler {
287            interpolation: Interpolation::Linear,
288            times: vec![0.0, 1.0],
289            values: TrackValues::Quat(vec![
290                Quat::IDENTITY,
291                Quat::from_rotation_x(std::f32::consts::FRAC_PI_2),
292            ]),
293        };
294        match s.sample(0.5) {
295            TrackValue::Quat(q) => {
296                let expected = Quat::from_rotation_x(std::f32::consts::FRAC_PI_4);
297                assert!(approx_eq_quat(q, expected, 1e-4), "got {q:?}");
298            }
299            _ => panic!(),
300        }
301    }
302
303    #[test]
304    fn sample_into_overwrites_only_animated_channels() {
305        // Bind pose: joint 1 sits at (0, 0, 2) with no rotation.
306        let mut pose = Pose::identity(2);
307        pose.local_transforms[1] =
308            glam::Affine3A::from_translation(Vec3::new(0.0, 0.0, 2.0));
309
310        // Clip: rotate joint 1 only. Translation should be preserved from the
311        // bind pose because the clip has no translation track.
312        let clip = AnimationClip {
313            duration: 1.0,
314            tracks: vec![Track {
315                joint: 1,
316                channel: Channel::Rotation,
317                sampler: Sampler {
318                    interpolation: Interpolation::Linear,
319                    times: vec![0.0, 1.0],
320                    values: TrackValues::Quat(vec![
321                        Quat::IDENTITY,
322                        Quat::from_rotation_x(std::f32::consts::FRAC_PI_2),
323                    ]),
324                },
325            }],
326        };
327
328        clip.sample_into(0.5, &mut pose);
329
330        let (scale, rot, trans) = pose.local_transforms[1].to_scale_rotation_translation();
331        // Translation preserved from bind pose.
332        assert!(approx_eq_vec3(trans, Vec3::new(0.0, 0.0, 2.0), 1e-4));
333        // Rotation taken from the clip at t=0.5 (45 deg around X).
334        let expected_rot = Quat::from_rotation_x(std::f32::consts::FRAC_PI_4);
335        assert!(approx_eq_quat(rot, expected_rot, 1e-4));
336        // Scale untouched -> still 1.
337        assert!(approx_eq_vec3(scale, Vec3::ONE, 1e-4));
338    }
339
340    #[test]
341    fn sample_into_two_tracks_compose_correctly() {
342        // Joint 0 bind pose is identity. Animate both translation and scale.
343        let mut pose = Pose::identity(1);
344        let clip = AnimationClip {
345            duration: 2.0,
346            tracks: vec![
347                Track {
348                    joint: 0,
349                    channel: Channel::Translation,
350                    sampler: Sampler {
351                        interpolation: Interpolation::Linear,
352                        times: vec![0.0, 2.0],
353                        values: TrackValues::Vec3(vec![Vec3::ZERO, Vec3::new(4.0, 0.0, 0.0)]),
354                    },
355                },
356                Track {
357                    joint: 0,
358                    channel: Channel::Scale,
359                    sampler: Sampler {
360                        interpolation: Interpolation::Step,
361                        times: vec![0.0, 1.0],
362                        values: TrackValues::Vec3(vec![Vec3::ONE, Vec3::splat(2.0)]),
363                    },
364                },
365            ],
366        };
367
368        clip.sample_into(1.5, &mut pose);
369
370        let (scale, _rot, trans) = pose.local_transforms[0].to_scale_rotation_translation();
371        assert!(approx_eq_vec3(trans, Vec3::new(3.0, 0.0, 0.0), 1e-4));
372        assert!(approx_eq_vec3(scale, Vec3::splat(2.0), 1e-4));
373    }
374
375    #[test]
376    fn out_of_range_joint_index_is_skipped() {
377        let mut pose = Pose::identity(2);
378        let clip = AnimationClip {
379            duration: 1.0,
380            tracks: vec![Track {
381                joint: 99,
382                channel: Channel::Translation,
383                sampler: Sampler {
384                    interpolation: Interpolation::Step,
385                    times: vec![0.0],
386                    values: TrackValues::Vec3(vec![Vec3::new(1.0, 2.0, 3.0)]),
387                },
388            }],
389        };
390        clip.sample_into(0.5, &mut pose);
391        // Pose untouched.
392        assert_eq!(pose.local_transforms[0], glam::Affine3A::IDENTITY);
393        assert_eq!(pose.local_transforms[1], glam::Affine3A::IDENTITY);
394    }
395}