Skip to main content

pfx_core/
ease.rs

1use std::f32::consts::{FRAC_PI_2, TAU};
2
3pub use crate::motion::{
4    cubic_in, cubic_in_out, cubic_out, quad_in, quad_out, smootherstep, smoothstep,
5};
6
7const BACK: f32 = 1.70158;
8const BACK_IN_OUT: f32 = BACK * 1.525;
9const ELASTIC: f32 = 3.0;
10const ELASTIC_IN_OUT: f32 = 4.5;
11
12fn shaped(t: f32, curve: impl Fn(f32) -> f32) -> f32 {
13    let t = t.clamp(0.0, 1.0);
14    if t <= 0.0 {
15        0.0
16    } else if t >= 1.0 {
17        1.0
18    } else {
19        curve(t)
20    }
21}
22
23fn folded(t: f32, rising: impl Fn(f32) -> f32) -> f32 {
24    let t = t.clamp(0.0, 1.0);
25    let u = if t < 0.5 { t } else { 1.0 - t };
26    let y = rising(2.0 * u) * 0.5;
27    if t < 0.5 { y } else { 1.0 - y }
28}
29
30pub fn linear(t: f32) -> f32 {
31    t.clamp(0.0, 1.0)
32}
33
34pub fn sine_in(t: f32) -> f32 {
35    shaped(t, |t| 1.0 - (t * FRAC_PI_2).cos())
36}
37
38pub fn sine_out(t: f32) -> f32 {
39    shaped(t, |t| (t * FRAC_PI_2).sin())
40}
41
42pub fn sine_in_out(t: f32) -> f32 {
43    folded(t, sine_in)
44}
45
46pub fn quad_in_out(t: f32) -> f32 {
47    folded(t, quad_in)
48}
49
50pub fn quart_in(t: f32) -> f32 {
51    shaped(t, |t| {
52        let s = t * t;
53        s * s
54    })
55}
56
57pub fn quart_out(t: f32) -> f32 {
58    shaped(t, |t| {
59        let u = 1.0 - t;
60        let s = u * u;
61        1.0 - s * s
62    })
63}
64
65pub fn quart_in_out(t: f32) -> f32 {
66    folded(t, quart_in)
67}
68
69pub fn quint_in(t: f32) -> f32 {
70    shaped(t, |t| {
71        let s = t * t;
72        s * s * t
73    })
74}
75
76pub fn quint_out(t: f32) -> f32 {
77    shaped(t, |t| {
78        let u = 1.0 - t;
79        let s = u * u;
80        1.0 - s * s * u
81    })
82}
83
84pub fn quint_in_out(t: f32) -> f32 {
85    folded(t, quint_in)
86}
87
88pub fn expo_in(t: f32) -> f32 {
89    shaped(t, |t| (10.0 * t - 10.0).exp2())
90}
91
92pub fn expo_out(t: f32) -> f32 {
93    shaped(t, |t| 1.0 - (-10.0 * t).exp2())
94}
95
96pub fn expo_in_out(t: f32) -> f32 {
97    folded(t, expo_in)
98}
99
100pub fn circ_in(t: f32) -> f32 {
101    shaped(t, |t| 1.0 - (1.0 - t * t).sqrt())
102}
103
104pub fn circ_out(t: f32) -> f32 {
105    shaped(t, |t| {
106        let u = t - 1.0;
107        (1.0 - u * u).sqrt()
108    })
109}
110
111pub fn circ_in_out(t: f32) -> f32 {
112    folded(t, circ_in)
113}
114
115fn back_in_with(t: f32, overshoot: f32) -> f32 {
116    shaped(t, |t| t * t * ((overshoot + 1.0) * t - overshoot))
117}
118
119pub fn back_in(t: f32) -> f32 {
120    back_in_with(t, BACK)
121}
122
123pub fn back_out(t: f32) -> f32 {
124    shaped(t, |t| {
125        let u = t - 1.0;
126        1.0 + u * u * ((BACK + 1.0) * u + BACK)
127    })
128}
129
130pub fn back_in_out(t: f32) -> f32 {
131    folded(t, |t| back_in_with(t, BACK_IN_OUT))
132}
133
134fn elastic_in_with(t: f32, period: f32) -> f32 {
135    shaped(t, |t| {
136        let phase = (10.0 * t - 10.0 - period * 0.25) * (TAU / period);
137        -(10.0 * t - 10.0).exp2() * phase.sin()
138    })
139}
140
141pub fn elastic_in(t: f32) -> f32 {
142    elastic_in_with(t, ELASTIC)
143}
144
145pub fn elastic_out(t: f32) -> f32 {
146    1.0 - elastic_in(1.0 - t.clamp(0.0, 1.0))
147}
148
149pub fn elastic_in_out(t: f32) -> f32 {
150    folded(t, |t| elastic_in_with(t, ELASTIC_IN_OUT))
151}
152
153pub fn bounce_out(t: f32) -> f32 {
154    shaped(t, |t| {
155        const N: f32 = 7.5625;
156        const D: f32 = 2.75;
157        if t < 1.0 / D {
158            N * t * t
159        } else if t < 2.0 / D {
160            let t = t - 1.5 / D;
161            N * t * t + 0.75
162        } else if t < 2.5 / D {
163            let t = t - 2.25 / D;
164            N * t * t + 0.9375
165        } else {
166            let t = t - 2.625 / D;
167            N * t * t + 0.984375
168        }
169    })
170}
171
172pub fn bounce_in(t: f32) -> f32 {
173    1.0 - bounce_out(1.0 - t.clamp(0.0, 1.0))
174}
175
176pub fn bounce_in_out(t: f32) -> f32 {
177    folded(t, bounce_in)
178}
179
180#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
181pub enum Ease {
182    #[default]
183    Linear,
184    SineIn,
185    SineOut,
186    SineInOut,
187    QuadIn,
188    QuadOut,
189    QuadInOut,
190    CubicIn,
191    CubicOut,
192    CubicInOut,
193    QuartIn,
194    QuartOut,
195    QuartInOut,
196    QuintIn,
197    QuintOut,
198    QuintInOut,
199    ExpoIn,
200    ExpoOut,
201    ExpoInOut,
202    CircIn,
203    CircOut,
204    CircInOut,
205    BackIn,
206    BackOut,
207    BackInOut,
208    ElasticIn,
209    ElasticOut,
210    ElasticInOut,
211    BounceIn,
212    BounceOut,
213    BounceInOut,
214}
215
216type Curve = fn(f32) -> f32;
217
218const TABLE: [(Ease, &str, Curve); 31] = [
219    (Ease::Linear, "linear", linear),
220    (Ease::SineIn, "sine_in", sine_in),
221    (Ease::SineOut, "sine_out", sine_out),
222    (Ease::SineInOut, "sine_in_out", sine_in_out),
223    (Ease::QuadIn, "quad_in", quad_in),
224    (Ease::QuadOut, "quad_out", quad_out),
225    (Ease::QuadInOut, "quad_in_out", quad_in_out),
226    (Ease::CubicIn, "cubic_in", cubic_in),
227    (Ease::CubicOut, "cubic_out", cubic_out),
228    (Ease::CubicInOut, "cubic_in_out", cubic_in_out),
229    (Ease::QuartIn, "quart_in", quart_in),
230    (Ease::QuartOut, "quart_out", quart_out),
231    (Ease::QuartInOut, "quart_in_out", quart_in_out),
232    (Ease::QuintIn, "quint_in", quint_in),
233    (Ease::QuintOut, "quint_out", quint_out),
234    (Ease::QuintInOut, "quint_in_out", quint_in_out),
235    (Ease::ExpoIn, "expo_in", expo_in),
236    (Ease::ExpoOut, "expo_out", expo_out),
237    (Ease::ExpoInOut, "expo_in_out", expo_in_out),
238    (Ease::CircIn, "circ_in", circ_in),
239    (Ease::CircOut, "circ_out", circ_out),
240    (Ease::CircInOut, "circ_in_out", circ_in_out),
241    (Ease::BackIn, "back_in", back_in),
242    (Ease::BackOut, "back_out", back_out),
243    (Ease::BackInOut, "back_in_out", back_in_out),
244    (Ease::ElasticIn, "elastic_in", elastic_in),
245    (Ease::ElasticOut, "elastic_out", elastic_out),
246    (Ease::ElasticInOut, "elastic_in_out", elastic_in_out),
247    (Ease::BounceIn, "bounce_in", bounce_in),
248    (Ease::BounceOut, "bounce_out", bounce_out),
249    (Ease::BounceInOut, "bounce_in_out", bounce_in_out),
250];
251
252impl Ease {
253    pub const ALL: [Ease; 31] = [
254        Ease::Linear,
255        Ease::SineIn,
256        Ease::SineOut,
257        Ease::SineInOut,
258        Ease::QuadIn,
259        Ease::QuadOut,
260        Ease::QuadInOut,
261        Ease::CubicIn,
262        Ease::CubicOut,
263        Ease::CubicInOut,
264        Ease::QuartIn,
265        Ease::QuartOut,
266        Ease::QuartInOut,
267        Ease::QuintIn,
268        Ease::QuintOut,
269        Ease::QuintInOut,
270        Ease::ExpoIn,
271        Ease::ExpoOut,
272        Ease::ExpoInOut,
273        Ease::CircIn,
274        Ease::CircOut,
275        Ease::CircInOut,
276        Ease::BackIn,
277        Ease::BackOut,
278        Ease::BackInOut,
279        Ease::ElasticIn,
280        Ease::ElasticOut,
281        Ease::ElasticInOut,
282        Ease::BounceIn,
283        Ease::BounceOut,
284        Ease::BounceInOut,
285    ];
286
287    pub fn at(self, t: f32) -> f32 {
288        (TABLE[self as usize].2)(t)
289    }
290
291    pub fn name(self) -> &'static str {
292        TABLE[self as usize].1
293    }
294
295    pub fn from_name(name: &str) -> Option<Ease> {
296        TABLE
297            .iter()
298            .find(|entry| entry.1 == name)
299            .map(|entry| entry.0)
300    }
301
302    pub fn monotonic(self) -> bool {
303        !matches!(
304            self,
305            Ease::BackIn
306                | Ease::BackOut
307                | Ease::BackInOut
308                | Ease::ElasticIn
309                | Ease::ElasticOut
310                | Ease::ElasticInOut
311                | Ease::BounceIn
312                | Ease::BounceOut
313                | Ease::BounceInOut
314        )
315    }
316
317    pub fn symmetric(self) -> bool {
318        matches!(
319            self,
320            Ease::Linear
321                | Ease::SineInOut
322                | Ease::QuadInOut
323                | Ease::CubicInOut
324                | Ease::QuartInOut
325                | Ease::QuintInOut
326                | Ease::ExpoInOut
327                | Ease::CircInOut
328                | Ease::BackInOut
329                | Ease::ElasticInOut
330                | Ease::BounceInOut
331        )
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    const NAMES: [&str; 31] = [
340        "linear",
341        "sine_in",
342        "sine_out",
343        "sine_in_out",
344        "quad_in",
345        "quad_out",
346        "quad_in_out",
347        "cubic_in",
348        "cubic_out",
349        "cubic_in_out",
350        "quart_in",
351        "quart_out",
352        "quart_in_out",
353        "quint_in",
354        "quint_out",
355        "quint_in_out",
356        "expo_in",
357        "expo_out",
358        "expo_in_out",
359        "circ_in",
360        "circ_out",
361        "circ_in_out",
362        "back_in",
363        "back_out",
364        "back_in_out",
365        "elastic_in",
366        "elastic_out",
367        "elastic_in_out",
368        "bounce_in",
369        "bounce_out",
370        "bounce_in_out",
371    ];
372
373    const SAMPLES: u32 = 4000;
374
375    fn sweep(ease: Ease) -> impl Iterator<Item = (f32, f32)> {
376        (0..=SAMPLES).map(move |i| {
377            let t = i as f32 / SAMPLES as f32;
378            (t, ease.at(t))
379        })
380    }
381
382    #[test]
383    fn the_name_table_is_exhaustive_and_in_order() {
384        assert_eq!(Ease::ALL.len(), NAMES.len());
385        assert_eq!(TABLE.len(), NAMES.len());
386        for (index, (ease, name)) in Ease::ALL.iter().zip(NAMES).enumerate() {
387            assert_eq!(*ease as usize, index);
388            assert_eq!(TABLE[index].0, *ease);
389            assert_eq!(ease.name(), name);
390            assert_eq!(Ease::from_name(name), Some(*ease));
391        }
392        assert_eq!(Ease::from_name("Linear"), None);
393        assert_eq!(Ease::from_name("sine"), None);
394        assert_eq!(Ease::from_name(""), None);
395        assert_eq!(Ease::default(), Ease::Linear);
396        let mut sorted = NAMES.to_vec();
397        sorted.sort_unstable();
398        sorted.dedup();
399        assert_eq!(sorted.len(), NAMES.len());
400    }
401
402    #[test]
403    fn the_table_calls_the_named_functions() {
404        let pairs: [(Ease, Curve); 31] = [
405            (Ease::Linear, linear),
406            (Ease::SineIn, sine_in),
407            (Ease::SineOut, sine_out),
408            (Ease::SineInOut, sine_in_out),
409            (Ease::QuadIn, quad_in),
410            (Ease::QuadOut, quad_out),
411            (Ease::QuadInOut, quad_in_out),
412            (Ease::CubicIn, cubic_in),
413            (Ease::CubicOut, cubic_out),
414            (Ease::CubicInOut, cubic_in_out),
415            (Ease::QuartIn, quart_in),
416            (Ease::QuartOut, quart_out),
417            (Ease::QuartInOut, quart_in_out),
418            (Ease::QuintIn, quint_in),
419            (Ease::QuintOut, quint_out),
420            (Ease::QuintInOut, quint_in_out),
421            (Ease::ExpoIn, expo_in),
422            (Ease::ExpoOut, expo_out),
423            (Ease::ExpoInOut, expo_in_out),
424            (Ease::CircIn, circ_in),
425            (Ease::CircOut, circ_out),
426            (Ease::CircInOut, circ_in_out),
427            (Ease::BackIn, back_in),
428            (Ease::BackOut, back_out),
429            (Ease::BackInOut, back_in_out),
430            (Ease::ElasticIn, elastic_in),
431            (Ease::ElasticOut, elastic_out),
432            (Ease::ElasticInOut, elastic_in_out),
433            (Ease::BounceIn, bounce_in),
434            (Ease::BounceOut, bounce_out),
435            (Ease::BounceInOut, bounce_in_out),
436        ];
437        for (ease, function) in pairs {
438            for (t, y) in sweep(ease) {
439                assert_eq!(y.to_bits(), function(t).to_bits(), "{}", ease.name());
440            }
441        }
442    }
443
444    #[test]
445    fn every_curve_starts_at_zero_ends_at_one_and_clamps() {
446        for ease in Ease::ALL {
447            let name = ease.name();
448            assert_eq!(ease.at(0.0), 0.0, "{name}");
449            assert_eq!(ease.at(1.0), 1.0, "{name}");
450            assert_eq!(ease.at(-0.4), 0.0, "{name}");
451            assert_eq!(ease.at(-1e9), 0.0, "{name}");
452            assert_eq!(ease.at(1.7), 1.0, "{name}");
453            assert_eq!(ease.at(1e9), 1.0, "{name}");
454            assert_eq!(ease.at(f32::INFINITY), 1.0, "{name}");
455            assert_eq!(ease.at(f32::NEG_INFINITY), 0.0, "{name}");
456            for (_, y) in sweep(ease) {
457                assert!(y.is_finite(), "{name}");
458            }
459        }
460    }
461
462    #[test]
463    fn the_monotonic_curves_never_step_back() {
464        for ease in Ease::ALL {
465            let mut previous = 0.0;
466            for (t, y) in sweep(ease) {
467                if ease.monotonic() {
468                    assert!(
469                        (0.0..=1.0).contains(&y) && y >= previous,
470                        "{} at {t}",
471                        ease.name()
472                    );
473                }
474                previous = y;
475            }
476        }
477        let wobbly = Ease::ALL.iter().filter(|e| !e.monotonic()).count();
478        assert_eq!(wobbly, 9);
479        for ease in Ease::ALL.iter().filter(|e| !e.monotonic()) {
480            let mut previous = 0.0;
481            let mut stepped_back = false;
482            for (_, y) in sweep(*ease) {
483                stepped_back |= y < previous;
484                previous = y;
485            }
486            assert!(stepped_back, "{}", ease.name());
487        }
488    }
489
490    #[test]
491    fn in_and_out_mirror_each_other() {
492        let pairs = [
493            (Ease::SineIn, Ease::SineOut),
494            (Ease::QuadIn, Ease::QuadOut),
495            (Ease::CubicIn, Ease::CubicOut),
496            (Ease::QuartIn, Ease::QuartOut),
497            (Ease::QuintIn, Ease::QuintOut),
498            (Ease::ExpoIn, Ease::ExpoOut),
499            (Ease::CircIn, Ease::CircOut),
500            (Ease::BackIn, Ease::BackOut),
501            (Ease::ElasticIn, Ease::ElasticOut),
502            (Ease::BounceIn, Ease::BounceOut),
503        ];
504        for (rising, falling) in pairs {
505            for i in 1..SAMPLES {
506                let t = i as f32 / SAMPLES as f32;
507                let mirrored = 1.0 - rising.at(1.0 - t);
508                let tolerance = if matches!(rising, Ease::ExpoIn | Ease::ElasticIn) {
509                    1e-3
510                } else {
511                    2e-6
512                };
513                assert!(
514                    (falling.at(t) - mirrored).abs() <= tolerance,
515                    "{} at {t}",
516                    falling.name()
517                );
518            }
519        }
520    }
521
522    #[test]
523    fn the_in_out_curves_are_point_symmetric_about_the_middle() {
524        for ease in Ease::ALL.iter().filter(|e| e.symmetric()) {
525            assert_eq!(ease.at(0.5), 0.5, "{}", ease.name());
526            for i in 0..=SAMPLES / 2 {
527                let t = i as f32 / SAMPLES as f32;
528                let sum = ease.at(t) + ease.at(1.0 - t);
529                assert!((sum - 1.0).abs() < 2e-6, "{} at {t}: {sum}", ease.name());
530            }
531        }
532        let named = Ease::ALL.iter().filter(|e| e.symmetric()).count();
533        assert_eq!(named, 11);
534    }
535
536    #[test]
537    fn known_midpoints() {
538        assert_eq!(quad_in_out(0.25), 0.125);
539        assert_eq!(quad_in_out(0.75), 0.875);
540        assert_eq!(quart_in(0.5), 0.0625);
541        assert_eq!(quart_out(0.5), 0.9375);
542        assert_eq!(quart_in_out(0.25), 0.03125);
543        assert_eq!(quint_in(0.5), 0.03125);
544        assert_eq!(quint_out(0.5), 0.96875);
545        assert_eq!(quint_in_out(0.25), 0.015625);
546        assert_eq!(expo_in(0.5), 2.0f32.powi(-5));
547        assert_eq!(expo_out(0.5), 1.0 - 2.0f32.powi(-5));
548        assert!((sine_in(0.5) - (1.0 - 0.5f32.sqrt())).abs() < 1e-6);
549        assert!((sine_out(0.5) - 0.5f32.sqrt()).abs() < 1e-6);
550        assert!((circ_in(0.6) - 0.2).abs() < 1e-6);
551        assert!((circ_out(0.6) - (1.0 - 0.16f32).sqrt()).abs() < 1e-6);
552        assert!((back_in(0.5) - 0.25 * (2.70158 * 0.5 - 1.70158)).abs() < 1e-6);
553        assert!((bounce_out(0.5) - 0.765625).abs() < 1e-6);
554        assert!((bounce_out(1.0 / 2.75) - 1.0).abs() < 1e-6);
555    }
556
557    #[test]
558    fn back_overshoots_by_about_a_tenth() {
559        let low = sweep(Ease::BackIn).map(|(_, y)| y).fold(0.0f32, f32::min);
560        assert!((-0.1001..-0.09).contains(&low), "{low}");
561        assert!(sweep(Ease::BackIn).all(|(_, y)| y <= 1.0));
562
563        let high = sweep(Ease::BackOut).map(|(_, y)| y).fold(1.0f32, f32::max);
564        assert!((1.09..1.1001).contains(&high), "{high}");
565        assert!(sweep(Ease::BackOut).all(|(_, y)| y >= 0.0));
566
567        let low = sweep(Ease::BackInOut)
568            .map(|(_, y)| y)
569            .fold(0.0f32, f32::min);
570        let high = sweep(Ease::BackInOut)
571            .map(|(_, y)| y)
572            .fold(1.0f32, f32::max);
573        assert!((-0.1002..-0.09).contains(&low), "{low}");
574        assert!((1.09..1.1002).contains(&high), "{high}");
575    }
576
577    #[test]
578    fn elastic_overshoots_and_rings_down() {
579        let low = sweep(Ease::ElasticIn)
580            .map(|(_, y)| y)
581            .fold(0.0f32, f32::min);
582        assert!((-0.4..-0.3).contains(&low), "{low}");
583        let high = sweep(Ease::ElasticOut)
584            .map(|(_, y)| y)
585            .fold(1.0f32, f32::max);
586        assert!((1.3..1.4).contains(&high), "{high}");
587        assert!(sweep(Ease::ElasticIn).all(|(_, y)| y <= 1.0));
588        assert!(sweep(Ease::ElasticOut).all(|(_, y)| y >= 0.0));
589
590        let low = sweep(Ease::ElasticInOut)
591            .map(|(_, y)| y)
592            .fold(0.0f32, f32::min);
593        let high = sweep(Ease::ElasticInOut)
594            .map(|(_, y)| y)
595            .fold(1.0f32, f32::max);
596        assert!((-0.2..-0.1).contains(&low), "{low}");
597        assert!((1.1..1.2).contains(&high), "{high}");
598
599        let mut peaks = Vec::new();
600        let mut previous = [0.0f32; 2];
601        for (_, y) in sweep(Ease::ElasticOut) {
602            if previous[1] > previous[0] && previous[1] > y && previous[1] > 1.0 {
603                peaks.push(previous[1] - 1.0);
604            }
605            previous = [previous[1], y];
606        }
607        assert!(peaks.len() >= 3, "{peaks:?}");
608        assert!(peaks.windows(2).all(|pair| pair[1] < pair[0]));
609    }
610
611    #[test]
612    fn bounce_stays_inside_the_unit_range() {
613        for ease in [Ease::BounceIn, Ease::BounceOut, Ease::BounceInOut] {
614            for (t, y) in sweep(ease) {
615                assert!((0.0..=1.0).contains(&y), "{} at {t}: {y}", ease.name());
616            }
617        }
618        let mut touches = 0;
619        let mut previous = [0.0f32; 2];
620        for (_, y) in sweep(Ease::BounceOut) {
621            if previous[1] < previous[0] && previous[1] < y {
622                touches += 1;
623            }
624            previous = [previous[1], y];
625        }
626        assert_eq!(touches, 3);
627    }
628
629    #[test]
630    fn the_older_easings_keep_their_results() {
631        assert_eq!(Ease::QuadIn.at(0.5), 0.25);
632        assert_eq!(Ease::QuadOut.at(0.5), 0.75);
633        assert_eq!(Ease::CubicIn.at(0.5), 0.125);
634        assert_eq!(Ease::CubicOut.at(0.5), 0.875);
635        assert_eq!(Ease::CubicInOut.at(0.25), 4.0 * 0.25 * 0.25 * 0.25);
636        assert_eq!(smoothstep(0.25), 0.15625);
637        assert_eq!(smootherstep(0.5), 0.5);
638        assert_eq!(linear(0.3), 0.3);
639    }
640}