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}