Skip to main content

oxideav_scene/
animation.rs

1//! Keyframe-based property animations.
2//!
3//! An [`Animation`] targets a single [`AnimatedProperty`] and carries
4//! a time-sorted list of [`Keyframe`]s. Between consecutive keyframes
5//! the value is interpolated according to the animation's default
6//! [`Easing`] — each keyframe can override this for the segment
7//! leading up to it.
8
9use crate::duration::TimeStamp;
10
11/// A keyframe track on one property.
12#[derive(Clone, Debug)]
13pub struct Animation {
14    pub property: AnimatedProperty,
15    pub keyframes: Vec<Keyframe>,
16    pub easing: Easing,
17    pub repeat: Repeat,
18}
19
20impl Animation {
21    /// Build an animation from a property + an unordered keyframe list.
22    /// The constructor sorts keyframes by time.
23    pub fn new(
24        property: AnimatedProperty,
25        mut keyframes: Vec<Keyframe>,
26        easing: Easing,
27        repeat: Repeat,
28    ) -> Self {
29        keyframes.sort_by_key(|k| k.time);
30        Animation {
31            property,
32            keyframes,
33            easing,
34            repeat,
35        }
36    }
37
38    /// Evaluate the animation at scene time `t`. Returns `None` if
39    /// the animation has no keyframes. For `Repeat::Once`, times
40    /// before the first keyframe clamp to the first value; times
41    /// after the last clamp to the last value.
42    pub fn sample(&self, t: TimeStamp) -> Option<KeyframeValue> {
43        if self.keyframes.is_empty() {
44            return None;
45        }
46        let t = match self.repeat {
47            Repeat::Once => t,
48            Repeat::Loop => {
49                let span = self.span()?;
50                if span <= 0 {
51                    t
52                } else {
53                    self.keyframes[0].time + ((t - self.keyframes[0].time).rem_euclid(span))
54                }
55            }
56            Repeat::PingPong => {
57                let span = self.span()?;
58                if span <= 0 {
59                    t
60                } else {
61                    let offset = (t - self.keyframes[0].time).rem_euclid(span * 2);
62                    if offset < span {
63                        self.keyframes[0].time + offset
64                    } else {
65                        self.keyframes[0].time + span * 2 - offset
66                    }
67                }
68            }
69        };
70
71        if t <= self.keyframes[0].time {
72            return Some(self.keyframes[0].value.clone());
73        }
74        if t >= self.keyframes[self.keyframes.len() - 1].time {
75            return Some(self.keyframes[self.keyframes.len() - 1].value.clone());
76        }
77
78        // Find segment: last kf with time <= t.
79        let idx = self
80            .keyframes
81            .binary_search_by_key(&t, |k| k.time)
82            .unwrap_or_else(|i| i.saturating_sub(1));
83        let (a, b) = (&self.keyframes[idx], &self.keyframes[idx + 1]);
84        let span = (b.time - a.time) as f32;
85        let raw = if span <= 0.0 {
86            0.0
87        } else {
88            (t - a.time) as f32 / span
89        };
90        let segment_easing = b.easing.unwrap_or(self.easing);
91        let f = segment_easing.apply(raw);
92        Some(KeyframeValue::interpolate(&a.value, &b.value, f))
93    }
94
95    fn span(&self) -> Option<TimeStamp> {
96        let first = self.keyframes.first()?.time;
97        let last = self.keyframes.last()?.time;
98        Some(last - first)
99    }
100}
101
102/// Which property a track drives.
103#[non_exhaustive]
104#[derive(Clone, Debug, PartialEq, Eq)]
105pub enum AnimatedProperty {
106    Position,
107    Scale,
108    Rotation,
109    Opacity,
110    Skew,
111    Anchor,
112    Volume,
113    /// Scalar parameter of the Nth element in the object's `effects`
114    /// chain. `param` is the effect's own parameter name.
115    EffectParam {
116        effect_idx: usize,
117        param: &'static str,
118    },
119    /// Caller-defined — the [`crate::render::SceneSampler`] decides
120    /// how to apply it.
121    Custom(String),
122}
123
124/// One keyframe — a value at a point in time.
125#[derive(Clone, Debug)]
126pub struct Keyframe {
127    pub time: TimeStamp,
128    pub value: KeyframeValue,
129    /// Override the animation's default easing for the *incoming*
130    /// segment (the one ending at this keyframe).
131    pub easing: Option<Easing>,
132}
133
134/// Typed keyframe value. `interpolate` is invoked by [`Animation`]
135/// between two keyframes of the same variant.
136#[non_exhaustive]
137#[derive(Clone, Debug, PartialEq)]
138pub enum KeyframeValue {
139    Scalar(f32),
140    Vec2(f32, f32),
141    /// `0xRRGGBBAA` colour, interpolated per-channel in linear space.
142    Color(u32),
143    /// Held value — interpolate returns `a` below 1.0 and `b` at 1.0.
144    /// Use for "step" transitions.
145    Discrete(String),
146}
147
148impl KeyframeValue {
149    pub fn interpolate(a: &KeyframeValue, b: &KeyframeValue, t: f32) -> KeyframeValue {
150        match (a, b) {
151            (KeyframeValue::Scalar(x), KeyframeValue::Scalar(y)) => {
152                KeyframeValue::Scalar(x + (y - x) * t)
153            }
154            (KeyframeValue::Vec2(x1, y1), KeyframeValue::Vec2(x2, y2)) => {
155                KeyframeValue::Vec2(x1 + (x2 - x1) * t, y1 + (y2 - y1) * t)
156            }
157            (KeyframeValue::Color(a), KeyframeValue::Color(b)) => {
158                KeyframeValue::Color(lerp_color(*a, *b, t))
159            }
160            (KeyframeValue::Discrete(a), KeyframeValue::Discrete(b)) => {
161                KeyframeValue::Discrete(if t < 1.0 { a.clone() } else { b.clone() })
162            }
163            // Different variants → hold the first.
164            _ => a.clone(),
165        }
166    }
167}
168
169fn lerp_color(a: u32, b: u32, t: f32) -> u32 {
170    let ac = [
171        ((a >> 24) & 0xff) as f32,
172        ((a >> 16) & 0xff) as f32,
173        ((a >> 8) & 0xff) as f32,
174        (a & 0xff) as f32,
175    ];
176    let bc = [
177        ((b >> 24) & 0xff) as f32,
178        ((b >> 16) & 0xff) as f32,
179        ((b >> 8) & 0xff) as f32,
180        (b & 0xff) as f32,
181    ];
182    let mut out = 0u32;
183    for i in 0..4 {
184        let v = (ac[i] + (bc[i] - ac[i]) * t).clamp(0.0, 255.0) as u32;
185        out |= v << ((3 - i) * 8);
186    }
187    out
188}
189
190/// How an animation loops.
191#[non_exhaustive]
192#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
193pub enum Repeat {
194    /// Play once and hold the final value.
195    #[default]
196    Once,
197    /// Wrap back to the start and play forever.
198    Loop,
199    /// Play forward then backward, forever.
200    PingPong,
201}
202
203/// Interpolation curve applied to the raw 0..=1 segment fraction.
204#[non_exhaustive]
205#[derive(Clone, Copy, Debug, Default, PartialEq)]
206pub enum Easing {
207    #[default]
208    Linear,
209    EaseIn,
210    EaseOut,
211    EaseInOut,
212    /// CSS `cubic-bezier(x1, y1, x2, y2)`; Adobe After Effects
213    /// compatible.
214    CubicBezier(f32, f32, f32, f32),
215    /// `N` discrete steps (staircase).
216    Step(u32),
217    /// Hold the starting value until the end, then jump.
218    Hold,
219}
220
221impl Easing {
222    /// Map a raw 0..=1 fraction through the easing curve.
223    pub fn apply(&self, t: f32) -> f32 {
224        let t = t.clamp(0.0, 1.0);
225        match self {
226            Easing::Linear => t,
227            Easing::EaseIn => t * t,
228            Easing::EaseOut => 1.0 - (1.0 - t) * (1.0 - t),
229            Easing::EaseInOut => {
230                if t < 0.5 {
231                    2.0 * t * t
232                } else {
233                    1.0 - 2.0 * (1.0 - t) * (1.0 - t)
234                }
235            }
236            Easing::CubicBezier(x1, y1, x2, y2) => cubic_bezier_eval(t, *x1, *y1, *x2, *y2),
237            Easing::Step(n) if *n > 0 => {
238                let n = *n as f32;
239                (t * n).floor() / n
240            }
241            Easing::Step(_) => 0.0,
242            Easing::Hold => {
243                if t >= 1.0 {
244                    1.0
245                } else {
246                    0.0
247                }
248            }
249        }
250    }
251}
252
253/// Evaluate a cubic Bezier easing at `t` ∈ [0, 1]. Implements the
254/// CSS `cubic-bezier(x1, y1, x2, y2)` spec — converts input `t` into
255/// the progress coordinate `x` and returns the `y` at that point.
256/// Uses a couple rounds of Newton-Raphson then a binary bracket
257/// fallback to stay bit-stable under tight schedules.
258fn cubic_bezier_eval(t: f32, x1: f32, y1: f32, x2: f32, y2: f32) -> f32 {
259    // B(t) = 3(1-t)²t · P1 + 3(1-t)t² · P2 + t³ · 1
260    fn b(t: f32, p1: f32, p2: f32) -> f32 {
261        let u = 1.0 - t;
262        3.0 * u * u * t * p1 + 3.0 * u * t * t * p2 + t * t * t
263    }
264    // derivative of B(t) with P0=0, P3=1
265    fn db(t: f32, p1: f32, p2: f32) -> f32 {
266        let u = 1.0 - t;
267        3.0 * u * u * p1 + 6.0 * u * t * (p2 - p1) + 3.0 * t * t * (1.0 - p2)
268    }
269    // Newton.
270    let mut guess = t;
271    for _ in 0..4 {
272        let d = db(guess, x1, x2);
273        if d.abs() < 1e-6 {
274            break;
275        }
276        let x = b(guess, x1, x2) - t;
277        guess -= x / d;
278        guess = guess.clamp(0.0, 1.0);
279    }
280    // Bisection fallback — capped at a few iterations to stay cheap.
281    let mut lo = 0.0;
282    let mut hi = 1.0;
283    for _ in 0..16 {
284        let x = b(guess, x1, x2);
285        if (x - t).abs() < 1e-5 {
286            break;
287        }
288        if x < t {
289            lo = guess;
290        } else {
291            hi = guess;
292        }
293        guess = 0.5 * (lo + hi);
294    }
295    b(guess, y1, y2)
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn sample_before_first_keyframe_clamps() {
304        let anim = Animation::new(
305            AnimatedProperty::Opacity,
306            vec![
307                Keyframe {
308                    time: 10,
309                    value: KeyframeValue::Scalar(0.0),
310                    easing: None,
311                },
312                Keyframe {
313                    time: 20,
314                    value: KeyframeValue::Scalar(1.0),
315                    easing: None,
316                },
317            ],
318            Easing::Linear,
319            Repeat::Once,
320        );
321        assert_eq!(anim.sample(0), Some(KeyframeValue::Scalar(0.0)));
322        assert_eq!(anim.sample(10), Some(KeyframeValue::Scalar(0.0)));
323    }
324
325    #[test]
326    fn sample_linear_midpoint() {
327        let anim = Animation::new(
328            AnimatedProperty::Opacity,
329            vec![
330                Keyframe {
331                    time: 0,
332                    value: KeyframeValue::Scalar(0.0),
333                    easing: None,
334                },
335                Keyframe {
336                    time: 10,
337                    value: KeyframeValue::Scalar(10.0),
338                    easing: None,
339                },
340            ],
341            Easing::Linear,
342            Repeat::Once,
343        );
344        match anim.sample(5).unwrap() {
345            KeyframeValue::Scalar(v) => assert!((v - 5.0).abs() < 1e-3),
346            _ => panic!("wrong variant"),
347        }
348    }
349
350    #[test]
351    fn easing_in_out_crosses_half_at_half() {
352        assert!((Easing::EaseInOut.apply(0.5) - 0.5).abs() < 1e-5);
353    }
354
355    #[test]
356    fn cubic_bezier_endpoints() {
357        // CSS ease: cubic-bezier(0.25, 0.1, 0.25, 1.0)
358        let e = Easing::CubicBezier(0.25, 0.1, 0.25, 1.0);
359        assert!((e.apply(0.0) - 0.0).abs() < 1e-3);
360        assert!((e.apply(1.0) - 1.0).abs() < 1e-3);
361    }
362
363    #[test]
364    fn repeat_loop_wraps() {
365        let anim = Animation::new(
366            AnimatedProperty::Opacity,
367            vec![
368                Keyframe {
369                    time: 0,
370                    value: KeyframeValue::Scalar(0.0),
371                    easing: None,
372                },
373                Keyframe {
374                    time: 10,
375                    value: KeyframeValue::Scalar(10.0),
376                    easing: None,
377                },
378            ],
379            Easing::Linear,
380            Repeat::Loop,
381        );
382        // At t=15 we've wrapped back to t=5, so value=5.
383        match anim.sample(15).unwrap() {
384            KeyframeValue::Scalar(v) => assert!((v - 5.0).abs() < 1e-3),
385            _ => panic!("wrong variant"),
386        }
387    }
388
389    #[test]
390    fn color_interpolation_halfway() {
391        let c = KeyframeValue::interpolate(
392            &KeyframeValue::Color(0xFF0000FF),
393            &KeyframeValue::Color(0x0000FFFF),
394            0.5,
395        );
396        match c {
397            KeyframeValue::Color(v) => {
398                let r = (v >> 24) & 0xff;
399                let b = (v >> 8) & 0xff;
400                assert!(r > 100 && r < 155, "r={r}");
401                assert!(b > 100 && b < 155, "b={b}");
402            }
403            _ => panic!("wrong variant"),
404        }
405    }
406}