Skip to main content

retroglyph_core/animate/
easing.rs

1//! [`Easing`]: normalized curves reshaping linear progress into eased motion.
2
3/// A normalized easing curve: reshapes a linear progress fraction (`0.0..=1.0`) into an eased
4/// one, the same named curves as CSS transitions and <https://easings.net>.
5///
6/// [`Linear`](Self::Linear) is the default. The `In` variants start slow, `Out` variants end
7/// slow, and `InOut` variants do both (matching the usual naming convention: "In" describes the
8/// *start* of the motion, not a direction).
9///
10/// [`EaseOutElastic`](Self::EaseOutElastic) and [`EaseOutBounce`](Self::EaseOutBounce) are the
11/// only curves in their families: both are used for a settle/overshoot effect at the *end* of a
12/// motion, and the in/in-out variants (the same shape mirrored to the start) are uncommon enough
13/// in practice that this curated set omits them.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
15pub enum Easing {
16    /// Constant speed: `t` unchanged.
17    #[default]
18    Linear,
19    /// Starts slow, accelerates, quadratically.
20    EaseInQuad,
21    /// Starts fast, decelerates, quadratically.
22    EaseOutQuad,
23    /// Slow -> fast -> slow, quadratically.
24    EaseInOutQuad,
25    /// Starts slow, accelerates, cubically -- a stronger version of [`EaseInQuad`](Self::EaseInQuad).
26    EaseInCubic,
27    /// Starts fast, decelerates, cubically -- a stronger version of [`EaseOutQuad`](Self::EaseOutQuad).
28    EaseOutCubic,
29    /// Slow -> fast -> slow, cubically -- a stronger version of [`EaseInOutQuad`](Self::EaseInOutQuad).
30    EaseInOutCubic,
31    /// A gentle sine-shaped start.
32    EaseInSine,
33    /// A gentle sine-shaped end.
34    EaseOutSine,
35    /// A gentle sine-shaped start and end.
36    EaseInOutSine,
37    /// Springs past the target and oscillates back before settling, going outside `0.0..=1.0`
38    /// for part of the curve.
39    EaseOutElastic,
40    /// Bounces (like a dropped ball) to a stop at the target.
41    EaseOutBounce,
42}
43
44impl Easing {
45    /// Applies this curve to `t` (clamped to `0.0..=1.0` first), returning the eased fraction.
46    #[must_use]
47    pub fn apply(self, t: f32) -> f32 {
48        let t = t.clamp(0.0, 1.0);
49        match self {
50            Self::Linear => t,
51            Self::EaseInQuad => t * t,
52            // Not `f32::mul_add`: it's a std-only inherent method, not in `core`. `libm::fmaf`
53            // is the no_std-safe equivalent (and this crate already depends on libm for the
54            // trig curves below).
55            Self::EaseOutQuad => libm::fmaf(t, -t, 2.0 * t),
56            Self::EaseInOutQuad => {
57                if t < 0.5 {
58                    2.0 * t * t
59                } else {
60                    let u = libm::fmaf(-2.0, t, 2.0);
61                    1.0 - u * u / 2.0
62                }
63            }
64            Self::EaseInCubic => t * t * t,
65            Self::EaseOutCubic => {
66                let u = 1.0 - t;
67                libm::fmaf(u * u, -u, 1.0)
68            }
69            Self::EaseInOutCubic => {
70                if t < 0.5 {
71                    4.0 * t * t * t
72                } else {
73                    let u = libm::fmaf(-2.0, t, 2.0);
74                    1.0 - u * u * u / 2.0
75                }
76            }
77            Self::EaseInSine => 1.0 - libm::cosf(t * core::f32::consts::FRAC_PI_2),
78            Self::EaseOutSine => libm::sinf(t * core::f32::consts::FRAC_PI_2),
79            Self::EaseInOutSine => -(libm::cosf(core::f32::consts::PI * t) - 1.0) / 2.0,
80            Self::EaseOutElastic => ease_out_elastic(t),
81            Self::EaseOutBounce => ease_out_bounce(t),
82        }
83    }
84}
85
86/// `t * (10 * t - 10.75) * (2 pi / 3)`'s sine, decayed by `2^(-10t)` -- see
87/// <https://easings.net/#easeOutElastic>.
88fn ease_out_elastic(t: f32) -> f32 {
89    const C4: f32 = 2.0 * core::f32::consts::PI / 3.0;
90
91    if t <= 0.0 {
92        return 0.0;
93    }
94    if t >= 1.0 {
95        return 1.0;
96    }
97    libm::fmaf(
98        libm::powf(2.0, -10.0 * t),
99        libm::sinf(libm::fmaf(10.0, t, -0.75) * C4),
100        1.0,
101    )
102}
103
104/// Four piecewise quadratic segments, each bouncing to a smaller peak -- see
105/// <https://easings.net/#easeOutBounce>.
106fn ease_out_bounce(t: f32) -> f32 {
107    const N1: f32 = 7.5625;
108    const D1: f32 = 2.75;
109    if t < 1.0 / D1 {
110        N1 * t * t
111    } else if t < 2.0 / D1 {
112        let t = t - 1.5 / D1;
113        libm::fmaf(N1 * t, t, 0.75)
114    } else if t < 2.5 / D1 {
115        let t = t - 2.25 / D1;
116        libm::fmaf(N1 * t, t, 0.9375)
117    } else {
118        let t = t - 2.625 / D1;
119        libm::fmaf(N1 * t, t, 0.984_375)
120    }
121}
122
123#[cfg(test)]
124#[allow(clippy::float_cmp)] // exact float equality is intentional throughout: every value
125// under test here is produced by simple, exactly-representable arithmetic (0.0, 1.0, halves),
126// not an accumulated or transcendental result where an epsilon comparison would be appropriate.
127mod tests {
128    use super::*;
129
130    #[test]
131    fn linear_is_identity() {
132        assert_eq!(Easing::Linear.apply(0.0), 0.0);
133        assert_eq!(Easing::Linear.apply(0.5), 0.5);
134        assert_eq!(Easing::Linear.apply(1.0), 1.0);
135    }
136
137    #[test]
138    fn every_curve_starts_at_0_and_ends_at_1() {
139        for easing in [
140            Easing::Linear,
141            Easing::EaseInQuad,
142            Easing::EaseOutQuad,
143            Easing::EaseInOutQuad,
144            Easing::EaseInCubic,
145            Easing::EaseOutCubic,
146            Easing::EaseInOutCubic,
147            Easing::EaseInSine,
148            Easing::EaseOutSine,
149            Easing::EaseInOutSine,
150            Easing::EaseOutElastic,
151            Easing::EaseOutBounce,
152        ] {
153            assert!(
154                (easing.apply(0.0) - 0.0).abs() < 1e-5,
155                "{easing:?} should start at 0"
156            );
157            assert!(
158                (easing.apply(1.0) - 1.0).abs() < 1e-5,
159                "{easing:?} should end at 1"
160            );
161        }
162    }
163
164    #[test]
165    fn ease_in_quad_starts_slower_than_linear() {
166        // "In" curves front-load less motion than linear during the first half.
167        assert!(Easing::EaseInQuad.apply(0.25) < 0.25);
168    }
169
170    #[test]
171    fn ease_out_quad_starts_faster_than_linear() {
172        assert!(Easing::EaseOutQuad.apply(0.25) > 0.25);
173    }
174
175    #[test]
176    fn out_of_range_input_is_clamped() {
177        assert_eq!(Easing::Linear.apply(-1.0), 0.0);
178        assert_eq!(Easing::Linear.apply(2.0), 1.0);
179    }
180
181    #[test]
182    #[allow(clippy::cast_precision_loss)] // i in 0..100 is always exactly representable in f32
183    fn elastic_overshoots_past_the_target() {
184        // The defining feature of an elastic curve: some t produces a value outside 0..=1.
185        let overshoots = (0..100)
186            .map(|i| Easing::EaseOutElastic.apply(i as f32 / 100.0))
187            .any(|v| !(0.0..=1.0).contains(&v));
188        assert!(overshoots);
189    }
190}