Skip to main content

gizmo_animation/
clip.rs

1use gizmo_math::{Quat, Vec3};
2
3use crate::hermite::{hermite_quat, hermite_vec3};
4
5/// Error returned when constructing a [`Track`] with inconsistent keyframe data.
6///
7/// A well-formed track requires its `keyframe_timestamps` to match the keyframe
8/// values one-to-one, to be sorted ascending, and to contain only finite values.
9/// Violating any of these invariants would otherwise lead to out-of-bounds
10/// indexing or `NaN`-poisoned interpolation at sample time, so [`Track::new`]
11/// rejects such data up front.
12#[derive(Debug, Clone, PartialEq, Eq)]
13#[non_exhaustive]
14pub enum TrackError {
15    /// The number of keyframe timestamps did not match the number of keyframe
16    /// values.
17    LengthMismatch {
18        /// Number of supplied timestamps.
19        timestamps: usize,
20        /// Number of supplied keyframe values.
21        values: usize,
22    },
23    /// A timestamp was `NaN` or infinite.
24    NonFiniteTimestamp {
25        /// Index of the offending timestamp.
26        index: usize,
27    },
28    /// Timestamps were not sorted in ascending order.
29    UnsortedTimestamps {
30        /// Index of the timestamp that was smaller than its predecessor.
31        index: usize,
32    },
33}
34
35impl std::fmt::Display for TrackError {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        match self {
38            TrackError::LengthMismatch { timestamps, values } => write!(
39                f,
40                "keyframe timestamp count ({timestamps}) does not match keyframe value count ({values})"
41            ),
42            TrackError::NonFiniteTimestamp { index } => {
43                write!(f, "keyframe timestamp at index {index} is not finite")
44            }
45            TrackError::UnsortedTimestamps { index } => write!(
46                f,
47                "keyframe timestamp at index {index} is not in ascending order"
48            ),
49        }
50    }
51}
52
53impl std::error::Error for TrackError {}
54
55/// The keyframe data for a [`Track`], one variant per animated transform channel.
56///
57/// Each vector is parallel to [`Track::keyframe_timestamps`]. Scale is a
58/// first-class channel: it is sampled and applied to the target's `Transform`
59/// just like translation and rotation.
60#[derive(Clone, Debug)]
61#[non_exhaustive]
62pub enum Keyframes {
63    /// Position keyframes (linearly interpolated).
64    Translation(Vec<Vec3>),
65    /// Rotation keyframes (spherically interpolated).
66    Rotation(Vec<Quat>),
67    /// Scale keyframes (linearly interpolated).
68    Scale(Vec<Vec3>),
69}
70
71impl Keyframes {
72    /// Returns the number of keyframe values held by this channel.
73    pub fn len(&self) -> usize {
74        match self {
75            Keyframes::Translation(v) => v.len(),
76            Keyframes::Rotation(v) => v.len(),
77            Keyframes::Scale(v) => v.len(),
78        }
79    }
80
81    /// Returns `true` if this channel holds no keyframe values.
82    pub fn is_empty(&self) -> bool {
83        self.len() == 0
84    }
85}
86
87/// How values between two keyframes are blended.
88///
89/// Mirrors the glTF animation sampler interpolation modes.
90#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
91pub enum Interpolation {
92    /// Linear (lerp for vectors, slerp for rotations).
93    #[default]
94    Linear,
95    /// Hold the value of the previous keyframe until the next one.
96    Step,
97    /// Cubic Hermite spline using per-keyframe in/out tangents. Requires
98    /// [`Track::tangents`] to be populated; falls back to `Linear` if absent.
99    CubicSpline,
100}
101
102/// In/out tangents for [`Interpolation::CubicSpline`].
103///
104/// glTF stores cubic-spline output accessors as interleaved
105/// `[in_tangent, value, out_tangent]` triples per keyframe; when loading, split
106/// them into the track's `keyframes` (the values) and this struct. Both tangent
107/// arrays must use the same [`Keyframes`] variant and length as the values.
108#[derive(Clone, Debug)]
109pub struct CubicTangents {
110    /// In-tangent per keyframe (aligned with `keyframe_timestamps`).
111    pub in_tangents: Keyframes,
112    /// Out-tangent per keyframe (aligned with `keyframe_timestamps`).
113    pub out_tangents: Keyframes,
114}
115
116/// A single animated channel targeting one named entity.
117#[derive(Clone, Debug)]
118#[non_exhaustive]
119pub struct Track {
120    /// Name of the entity this track animates (resolved at runtime).
121    pub target_name: String,
122    /// Keyframe times in seconds; must be sorted ascending and match `keyframes` in length.
123    pub keyframe_timestamps: Vec<f32>,
124    /// The keyframe values for this track.
125    pub keyframes: Keyframes,
126    /// How to blend between keyframes. Defaults to [`Interpolation::Linear`].
127    pub interpolation: Interpolation,
128    /// Cubic-spline tangents; only consulted when
129    /// `interpolation == Interpolation::CubicSpline`.
130    pub tangents: Option<CubicTangents>,
131}
132
133impl Track {
134    /// Creates a new linearly-interpolated track targeting `target_name` with the
135    /// given keyframe timestamps and values.
136    ///
137    /// # Errors
138    ///
139    /// Returns a [`TrackError`] if `keyframe_timestamps` does not match the
140    /// length of the data inside `keyframes`, if any timestamp is non-finite,
141    /// or if the timestamps are not sorted in ascending order. Enforcing these
142    /// invariants here guarantees that [`Track::sample`] cannot panic or emit
143    /// `NaN` values for a track built through this constructor.
144    pub fn new(
145        target_name: impl Into<String>,
146        keyframe_timestamps: Vec<f32>,
147        keyframes: Keyframes,
148    ) -> Result<Self, TrackError> {
149        let values = keyframes.len();
150        let timestamps = keyframe_timestamps.len();
151        if timestamps != values {
152            return Err(TrackError::LengthMismatch { timestamps, values });
153        }
154
155        let mut prev: Option<f32> = None;
156        for (index, &ts) in keyframe_timestamps.iter().enumerate() {
157            if !ts.is_finite() {
158                return Err(TrackError::NonFiniteTimestamp { index });
159            }
160            if let Some(p) = prev {
161                if ts < p {
162                    return Err(TrackError::UnsortedTimestamps { index });
163                }
164            }
165            prev = Some(ts);
166        }
167
168        Ok(Self {
169            target_name: target_name.into(),
170            keyframe_timestamps,
171            keyframes,
172            interpolation: Interpolation::Linear,
173            tangents: None,
174        })
175    }
176
177    /// Set the interpolation mode (builder style).
178    pub fn with_interpolation(mut self, interpolation: Interpolation) -> Self {
179        self.interpolation = interpolation;
180        self
181    }
182
183    /// Attach cubic-spline tangents and switch to [`Interpolation::CubicSpline`].
184    pub fn with_cubic_tangents(mut self, in_tangents: Keyframes, out_tangents: Keyframes) -> Self {
185        self.interpolation = Interpolation::CubicSpline;
186        self.tangents = Some(CubicTangents { in_tangents, out_tangents });
187        self
188    }
189
190    /// Returns the time of the last keyframe, or `0.0` if the track is empty.
191    pub fn duration(&self) -> f32 {
192        self.keyframe_timestamps.last().copied().unwrap_or(0.0)
193    }
194
195    /// Interpolates the track at a given time `t`.
196    ///
197    /// Times before the first or after the last keyframe are clamped to the
198    /// endpoint value. An empty track returns [`InterpolatedValue::None`].
199    pub fn sample(&self, t: f32) -> InterpolatedValue {
200        if self.keyframe_timestamps.is_empty() {
201            return InterpolatedValue::None;
202        }
203
204        if t <= *self.keyframe_timestamps.first().unwrap() {
205            return self.get_value(0);
206        }
207
208        if t >= *self.keyframe_timestamps.last().unwrap() {
209            return self.get_value(self.keyframe_timestamps.len() - 1);
210        }
211
212        // Find the segment.
213        let idx = self.keyframe_timestamps.partition_point(|&ts| ts <= t);
214        // `idx` is in `1..len` here (the t-before-first / t-after-last cases
215        // returned above), but guard the subtraction defensively in case of
216        // NaN timestamps that break the ordering assumptions of partition_point.
217        let idx1 = idx.clamp(1, self.keyframe_timestamps.len() - 1);
218        let idx0 = idx1 - 1;
219
220        let t0 = self.keyframe_timestamps[idx0];
221        let t1 = self.keyframe_timestamps[idx1];
222        // Guard against a zero/degenerate (or non-finite) segment span: two
223        // identical or out-of-order timestamps would produce a division by zero
224        // (NaN/Inf). Fall back to the start value rather than emitting NaN.
225        let segment = t1 - t0;
226        let factor = if segment.abs() > f32::EPSILON {
227            ((t - t0) / segment).clamp(0.0, 1.0)
228        } else {
229            0.0
230        };
231
232        match self.effective_interpolation() {
233            Interpolation::Step => self.get_value(idx0),
234            Interpolation::Linear => self.interpolate_linear(idx0, idx1, factor),
235            Interpolation::CubicSpline => self.interpolate_cubic(idx0, idx1, factor, segment),
236        }
237    }
238
239    /// The interpolation actually used: falls back to `Linear` if `CubicSpline`
240    /// was requested without tangents.
241    fn effective_interpolation(&self) -> Interpolation {
242        match self.interpolation {
243            Interpolation::CubicSpline if self.tangents.is_none() => Interpolation::Linear,
244            other => other,
245        }
246    }
247
248    fn get_value(&self, index: usize) -> InterpolatedValue {
249        // Bounds-checked access: the keyframe vectors are public and may be
250        // shorter than `keyframe_timestamps`, so a mismatched-length Track must
251        // not panic with an out-of-bounds index.
252        match &self.keyframes {
253            Keyframes::Translation(v) => match v.get(index) {
254                Some(&val) => InterpolatedValue::Translation(val),
255                None => InterpolatedValue::None,
256            },
257            Keyframes::Rotation(v) => match v.get(index) {
258                Some(&val) => InterpolatedValue::Rotation(val),
259                None => InterpolatedValue::None,
260            },
261            Keyframes::Scale(v) => match v.get(index) {
262                Some(&val) => InterpolatedValue::Scale(val),
263                None => InterpolatedValue::None,
264            },
265        }
266    }
267
268    fn interpolate_linear(&self, idx0: usize, idx1: usize, factor: f32) -> InterpolatedValue {
269        // Bounds-checked: a Track whose `keyframes` vector is shorter than its
270        // `keyframe_timestamps` must degrade gracefully instead of panicking.
271        match &self.keyframes {
272            Keyframes::Translation(v) => match (v.get(idx0), v.get(idx1)) {
273                (Some(&v0), Some(&v1)) => InterpolatedValue::Translation(v0.lerp(v1, factor)),
274                _ => InterpolatedValue::None,
275            },
276            Keyframes::Rotation(v) => match (v.get(idx0), v.get(idx1)) {
277                (Some(&v0), Some(&v1)) => InterpolatedValue::Rotation(v0.slerp(v1, factor)),
278                _ => InterpolatedValue::None,
279            },
280            Keyframes::Scale(v) => match (v.get(idx0), v.get(idx1)) {
281                (Some(&v0), Some(&v1)) => InterpolatedValue::Scale(v0.lerp(v1, factor)),
282                _ => InterpolatedValue::None,
283            },
284        }
285    }
286
287    /// Cubic Hermite spline using the real in/out tangents from the animation
288    /// format (glTF convention: `m0 = out_tangent[idx0] * dt`,
289    /// `m1 = in_tangent[idx1] * dt`, where `dt` is the segment duration).
290    ///
291    /// Falls back to [`Self::interpolate_linear`] if the value/tangent variants
292    /// disagree or the tangent arrays are shorter than the indexed keyframes, so
293    /// a malformed cubic track degrades gracefully instead of panicking.
294    fn interpolate_cubic(
295        &self,
296        idx0: usize,
297        idx1: usize,
298        factor: f32,
299        segment: f32,
300    ) -> InterpolatedValue {
301        // `effective_interpolation` guarantees tangents are present here.
302        let tangents = self.tangents.as_ref().expect("cubic requires tangents");
303        match (&self.keyframes, &tangents.in_tangents, &tangents.out_tangents) {
304            (Keyframes::Translation(v), Keyframes::Translation(in_t), Keyframes::Translation(out_t)) => {
305                match (v.get(idx0), out_t.get(idx0), v.get(idx1), in_t.get(idx1)) {
306                    (Some(&p0), Some(&m0), Some(&p1), Some(&m1)) => InterpolatedValue::Translation(
307                        hermite_vec3(p0, m0 * segment, p1, m1 * segment, factor),
308                    ),
309                    _ => self.interpolate_linear(idx0, idx1, factor),
310                }
311            }
312            (Keyframes::Scale(v), Keyframes::Scale(in_t), Keyframes::Scale(out_t)) => {
313                match (v.get(idx0), out_t.get(idx0), v.get(idx1), in_t.get(idx1)) {
314                    (Some(&p0), Some(&m0), Some(&p1), Some(&m1)) => InterpolatedValue::Scale(
315                        hermite_vec3(p0, m0 * segment, p1, m1 * segment, factor),
316                    ),
317                    _ => self.interpolate_linear(idx0, idx1, factor),
318                }
319            }
320            (Keyframes::Rotation(v), Keyframes::Rotation(in_t), Keyframes::Rotation(out_t)) => {
321                let scale = |q: Quat, s: f32| Quat::from_xyzw(q.x * s, q.y * s, q.z * s, q.w * s);
322                match (v.get(idx0), out_t.get(idx0), v.get(idx1), in_t.get(idx1)) {
323                    (Some(&p0), Some(&m0), Some(&p1), Some(&m1)) => InterpolatedValue::Rotation(
324                        hermite_quat(p0, scale(m0, segment), p1, scale(m1, segment), factor),
325                    ),
326                    _ => self.interpolate_linear(idx0, idx1, factor),
327                }
328            }
329            // Mismatched value/tangent variants: fall back to linear rather than panic.
330            _ => self.interpolate_linear(idx0, idx1, factor),
331        }
332    }
333}
334
335/// A value sampled from a [`Track`] at a specific time.
336///
337/// The variant indicates which transform channel the value belongs to.
338#[derive(Clone, Copy, Debug, PartialEq)]
339#[non_exhaustive]
340pub enum InterpolatedValue {
341    /// The track produced no value (e.g. it had no keyframes).
342    None,
343    /// A sampled position.
344    Translation(Vec3),
345    /// A sampled rotation.
346    Rotation(Quat),
347    /// A sampled scale.
348    Scale(Vec3),
349}
350
351/// Represents an animation sequence: a named collection of [`Track`]s.
352#[derive(Clone, Debug, Default)]
353#[non_exhaustive]
354pub struct AnimationClip {
355    /// Human-readable clip name.
356    pub name: String,
357    /// The tracks that make up this clip.
358    pub tracks: Vec<Track>,
359}
360
361impl AnimationClip {
362    /// Returns the length of the longest track, in seconds.
363    pub fn duration(&self) -> f32 {
364        self.tracks.iter().map(|t| t.duration()).fold(0.0, f32::max)
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    const TOL: f32 = 1e-4;
373
374    fn scale_track(interp: Interpolation) -> Track {
375        Track::new(
376            "bone",
377            vec![0.0, 1.0],
378            Keyframes::Scale(vec![Vec3::new(1.0, 1.0, 1.0), Vec3::new(2.0, 4.0, 8.0)]),
379        )
380        .expect("valid track")
381        .with_interpolation(interp)
382    }
383
384    #[test]
385    fn scale_track_linear_non_uniform() {
386        // A non-uniform scale keyed 1..(2,4,8) sampled at t=0.5 must lerp each axis
387        // independently. This is the regression guard for scale reaching the pose.
388        let track = scale_track(Interpolation::Linear);
389        match track.sample(0.5) {
390            InterpolatedValue::Scale(s) => {
391                assert!((s - Vec3::new(1.5, 2.5, 4.5)).length() < TOL, "got {s:?}");
392            }
393            other => panic!("expected Scale, got {other:?}"),
394        }
395    }
396
397    #[test]
398    fn scale_track_endpoints_and_clamp() {
399        let track = scale_track(Interpolation::Linear);
400        assert_eq!(track.sample(0.0), InterpolatedValue::Scale(Vec3::new(1.0, 1.0, 1.0)));
401        assert_eq!(track.sample(1.0), InterpolatedValue::Scale(Vec3::new(2.0, 4.0, 8.0)));
402        // Clamp beyond the ends.
403        assert_eq!(track.sample(-5.0), InterpolatedValue::Scale(Vec3::new(1.0, 1.0, 1.0)));
404        assert_eq!(track.sample(9.0), InterpolatedValue::Scale(Vec3::new(2.0, 4.0, 8.0)));
405    }
406
407    #[test]
408    fn scale_track_step_holds_previous() {
409        // Step interpolation did not exist before; a linear-only sampler would
410        // return (1.5,2.5,4.5) here instead of holding the first keyframe.
411        let track = scale_track(Interpolation::Step);
412        match track.sample(0.5) {
413            InterpolatedValue::Scale(s) => {
414                assert!((s - Vec3::new(1.0, 1.0, 1.0)).length() < TOL, "step should hold prev, got {s:?}");
415            }
416            other => panic!("expected Scale, got {other:?}"),
417        }
418    }
419
420    #[test]
421    fn scale_track_cubic_uses_real_tangents() {
422        // Cubic spline with real (non-zero) tangents must differ from the linear
423        // midpoint. A sampler that ignored tangents (or lacked cubic support)
424        // would return the linear value (1.5,2.5,4.5) and fail this test.
425        let values = Keyframes::Scale(vec![Vec3::new(1.0, 1.0, 1.0), Vec3::new(2.0, 4.0, 8.0)]);
426        // Steep out-tangent at k0, flat in-tangent at k1 -> curve overshoots the
427        // linear line in the first half.
428        let in_t = Keyframes::Scale(vec![Vec3::ZERO, Vec3::ZERO]);
429        let out_t = Keyframes::Scale(vec![Vec3::new(4.0, 4.0, 4.0), Vec3::ZERO]);
430        let track = Track::new("bone", vec![0.0, 1.0], values)
431            .unwrap()
432            .with_cubic_tangents(in_t, out_t);
433
434        let cubic = match track.sample(0.5) {
435            InterpolatedValue::Scale(s) => s,
436            other => panic!("expected Scale, got {other:?}"),
437        };
438        let linear = Vec3::new(1.5, 2.5, 4.5);
439        assert!((cubic - linear).length() > 0.1, "cubic must differ from linear, got {cubic:?}");
440
441        // Verify against the hand-computed Hermite value for the X axis.
442        // p0=1, m0=4*1 (dt=1), p1=2, m1=0, t=0.5:
443        // h00=0.5, h10=0.125, h01=0.5, h11=-0.125 -> 0.5*1 + 0.125*4 + 0.5*2 = 2.0
444        assert!((cubic.x - 2.0).abs() < TOL, "cubic X expected 2.0, got {}", cubic.x);
445    }
446
447    #[test]
448    fn cubic_without_tangents_falls_back_to_linear() {
449        let mut track = scale_track(Interpolation::CubicSpline);
450        track.tangents = None; // request cubic but provide nothing
451        match track.sample(0.5) {
452            InterpolatedValue::Scale(s) => assert!((s - Vec3::new(1.5, 2.5, 4.5)).length() < TOL),
453            other => panic!("expected Scale, got {other:?}"),
454        }
455    }
456
457    #[test]
458    fn empty_track_samples_none() {
459        let track = Track::new("bone", vec![], Keyframes::Scale(vec![])).unwrap();
460        assert_eq!(track.sample(0.5), InterpolatedValue::None);
461    }
462}