Skip to main content

goud_engine/core/math/
easing.rs

1//! Easing functions for smooth animations and transitions.
2//!
3//! Provides common easing curves (linear, quadratic, back, bounce) plus
4//! a cubic Bezier evaluator for custom curves.
5
6/// Type alias for easing functions: maps normalized time `t` in [0, 1] to a curved value.
7pub type EasingFn = fn(f32) -> f32;
8
9/// Linear easing (identity).
10#[inline]
11pub fn linear(t: f32) -> f32 {
12    t
13}
14
15/// Quadratic ease-in (slow start).
16#[inline]
17pub fn ease_in(t: f32) -> f32 {
18    t * t
19}
20
21/// Quadratic ease-out (slow end).
22#[inline]
23pub fn ease_out(t: f32) -> f32 {
24    t * (2.0 - t)
25}
26
27/// Quadratic ease-in-out (slow start and end).
28#[inline]
29pub fn ease_in_out(t: f32) -> f32 {
30    if t < 0.5 {
31        2.0 * t * t
32    } else {
33        -1.0 + (4.0 - 2.0 * t) * t
34    }
35}
36
37/// Ease-in with overshoot (back easing).
38///
39/// The curve dips below zero before accelerating to the target.
40#[inline]
41pub fn ease_in_back(t: f32) -> f32 {
42    const S: f32 = 1.70158;
43    t * t * ((S + 1.0) * t - S)
44}
45
46/// Bounce ease-out.
47///
48/// Simulates a bouncing effect at the end of the transition.
49#[inline]
50pub fn ease_out_bounce(t: f32) -> f32 {
51    const N1: f32 = 7.5625;
52    const D1: f32 = 2.75;
53
54    if t < 1.0 / D1 {
55        N1 * t * t
56    } else if t < 2.0 / D1 {
57        let t = t - 1.5 / D1;
58        N1 * t * t + 0.75
59    } else if t < 2.5 / D1 {
60        let t = t - 2.25 / D1;
61        N1 * t * t + 0.9375
62    } else {
63        let t = t - 2.625 / D1;
64        N1 * t * t + 0.984375
65    }
66}
67
68/// Cubic Bezier easing with two control points.
69///
70/// The curve starts at (0, 0) and ends at (1, 1). The control points
71/// `(x1, y1)` and `(x2, y2)` shape the curve between those endpoints.
72#[repr(C)]
73#[derive(Clone, Copy, Debug, PartialEq)]
74pub struct BezierEasing {
75    /// X coordinate of the first control point.
76    pub x1: f32,
77    /// Y coordinate of the first control point.
78    pub y1: f32,
79    /// X coordinate of the second control point.
80    pub x2: f32,
81    /// Y coordinate of the second control point.
82    pub y2: f32,
83}
84
85impl BezierEasing {
86    /// Creates a new cubic Bezier easing curve.
87    #[inline]
88    pub const fn new(x1: f32, y1: f32, x2: f32, y2: f32) -> Self {
89        Self { x1, y1, x2, y2 }
90    }
91
92    /// Evaluates the easing curve at time `t` in [0, 1].
93    ///
94    /// Uses Newton's method to find the parameter on the Bezier curve
95    /// whose X coordinate matches `t`, then returns the corresponding Y.
96    pub fn evaluate(&self, t: f32) -> f32 {
97        if t <= 0.0 {
98            return 0.0;
99        }
100        if t >= 1.0 {
101            return 1.0;
102        }
103
104        // Find the Bezier parameter `u` such that bezier_x(u) == t
105        // using Newton's method with binary search fallback.
106        let mut u = t; // initial guess
107        for _ in 0..8 {
108            let x = self.sample_x(u) - t;
109            if x.abs() < 1e-6 {
110                return self.sample_y(u);
111            }
112            let dx = self.sample_dx(u);
113            if dx.abs() < 1e-6 {
114                break;
115            }
116            u -= x / dx;
117        }
118
119        // Binary search fallback
120        let mut lo = 0.0_f32;
121        let mut hi = 1.0_f32;
122        u = t;
123        for _ in 0..20 {
124            let x = self.sample_x(u);
125            if (x - t).abs() < 1e-6 {
126                return self.sample_y(u);
127            }
128            if x < t {
129                lo = u;
130            } else {
131                hi = u;
132            }
133            u = (lo + hi) * 0.5;
134        }
135
136        self.sample_y(u)
137    }
138
139    /// Sample the X coordinate of the cubic Bezier at parameter `u`.
140    #[inline]
141    fn sample_x(&self, u: f32) -> f32 {
142        // B(u) = 3(1-u)^2 u P1 + 3(1-u) u^2 P2 + u^3
143        let u1 = 1.0 - u;
144        3.0 * u1 * u1 * u * self.x1 + 3.0 * u1 * u * u * self.x2 + u * u * u
145    }
146
147    /// Sample the Y coordinate of the cubic Bezier at parameter `u`.
148    #[inline]
149    fn sample_y(&self, u: f32) -> f32 {
150        let u1 = 1.0 - u;
151        3.0 * u1 * u1 * u * self.y1 + 3.0 * u1 * u * u * self.y2 + u * u * u
152    }
153
154    /// Derivative of the X coordinate with respect to `u`.
155    #[inline]
156    fn sample_dx(&self, u: f32) -> f32 {
157        let u1 = 1.0 - u;
158        3.0 * u1 * u1 * self.x1 + 6.0 * u1 * u * (self.x2 - self.x1) + 3.0 * u * u * (1.0 - self.x2)
159    }
160}
161
162/// Enumeration of built-in easing types plus custom cubic Bezier.
163#[derive(Clone, Copy, Debug, PartialEq)]
164pub enum Easing {
165    /// Linear interpolation (no easing).
166    Linear,
167    /// Quadratic ease-in.
168    EaseIn,
169    /// Quadratic ease-out.
170    EaseOut,
171    /// Quadratic ease-in-out.
172    EaseInOut,
173    /// Back ease-in (overshoot).
174    EaseInBack,
175    /// Bounce ease-out.
176    EaseOutBounce,
177    /// Custom cubic Bezier curve.
178    CubicBezier(BezierEasing),
179}
180
181impl Easing {
182    /// Applies this easing function to `t`.
183    #[inline]
184    pub fn apply(&self, t: f32) -> f32 {
185        match self {
186            Easing::Linear => linear(t),
187            Easing::EaseIn => ease_in(t),
188            Easing::EaseOut => ease_out(t),
189            Easing::EaseInOut => ease_in_out(t),
190            Easing::EaseInBack => ease_in_back(t),
191            Easing::EaseOutBounce => ease_out_bounce(t),
192            Easing::CubicBezier(bezier) => bezier.evaluate(t),
193        }
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    const EPSILON: f32 = 1e-5;
202
203    fn assert_boundaries(f: EasingFn, name: &str) {
204        assert!(
205            (f(0.0)).abs() < EPSILON,
206            "{name}: f(0) should be 0, got {}",
207            f(0.0)
208        );
209        assert!(
210            (f(1.0) - 1.0).abs() < EPSILON,
211            "{name}: f(1) should be 1, got {}",
212            f(1.0)
213        );
214    }
215
216    #[test]
217    fn test_linear_boundaries() {
218        assert_boundaries(linear, "linear");
219    }
220
221    #[test]
222    fn test_linear_midpoint() {
223        assert!((linear(0.5) - 0.5).abs() < EPSILON);
224    }
225
226    #[test]
227    fn test_ease_in_boundaries() {
228        assert_boundaries(ease_in, "ease_in");
229    }
230
231    #[test]
232    fn test_ease_in_is_slow_start() {
233        // At t=0.5, quadratic ease_in gives 0.25 (below linear 0.5)
234        assert!((ease_in(0.5) - 0.25).abs() < EPSILON);
235    }
236
237    #[test]
238    fn test_ease_out_boundaries() {
239        assert_boundaries(ease_out, "ease_out");
240    }
241
242    #[test]
243    fn test_ease_out_is_slow_end() {
244        // At t=0.5, ease_out gives 0.75 (above linear 0.5)
245        assert!((ease_out(0.5) - 0.75).abs() < EPSILON);
246    }
247
248    #[test]
249    fn test_ease_in_out_boundaries() {
250        assert_boundaries(ease_in_out, "ease_in_out");
251    }
252
253    #[test]
254    fn test_ease_in_out_midpoint() {
255        assert!((ease_in_out(0.5) - 0.5).abs() < EPSILON);
256    }
257
258    #[test]
259    fn test_ease_in_back_boundaries() {
260        assert_boundaries(ease_in_back, "ease_in_back");
261    }
262
263    #[test]
264    fn test_ease_in_back_overshoots_negative() {
265        // ease_in_back dips below zero near the start
266        assert!(ease_in_back(0.25) < 0.0);
267    }
268
269    #[test]
270    fn test_ease_out_bounce_boundaries() {
271        assert_boundaries(ease_out_bounce, "ease_out_bounce");
272    }
273
274    #[test]
275    fn test_ease_out_bounce_midpoint_above_half() {
276        // Bounce ease-out reaches above 0.5 before t=0.5
277        assert!(ease_out_bounce(0.5) > 0.5);
278    }
279
280    #[test]
281    fn test_bezier_linear() {
282        // Control points on the diagonal produce linear easing
283        let bezier = BezierEasing::new(0.0, 0.0, 1.0, 1.0);
284        assert!((bezier.evaluate(0.0)).abs() < EPSILON);
285        assert!((bezier.evaluate(1.0) - 1.0).abs() < EPSILON);
286        assert!((bezier.evaluate(0.5) - 0.5).abs() < 0.01);
287    }
288
289    #[test]
290    fn test_bezier_css_ease() {
291        // CSS "ease" curve: cubic-bezier(0.25, 0.1, 0.25, 1.0)
292        let bezier = BezierEasing::new(0.25, 0.1, 0.25, 1.0);
293        assert!((bezier.evaluate(0.0)).abs() < EPSILON);
294        assert!((bezier.evaluate(1.0) - 1.0).abs() < EPSILON);
295        // Midpoint should be above 0.5 for this curve
296        let mid = bezier.evaluate(0.5);
297        assert!(mid > 0.5, "CSS ease at 0.5 should be > 0.5, got {mid}");
298    }
299
300    #[test]
301    fn test_easing_enum_delegates() {
302        let easings = [
303            (Easing::Linear, linear as EasingFn),
304            (Easing::EaseIn, ease_in as EasingFn),
305            (Easing::EaseOut, ease_out as EasingFn),
306            (Easing::EaseInOut, ease_in_out as EasingFn),
307            (Easing::EaseInBack, ease_in_back as EasingFn),
308            (Easing::EaseOutBounce, ease_out_bounce as EasingFn),
309        ];
310        for (easing, func) in &easings {
311            for &t in &[0.0, 0.25, 0.5, 0.75, 1.0] {
312                assert!(
313                    (easing.apply(t) - func(t)).abs() < EPSILON,
314                    "Easing enum mismatch at t={t}"
315                );
316            }
317        }
318    }
319
320    #[test]
321    fn test_easing_enum_cubic_bezier() {
322        let bezier = BezierEasing::new(0.42, 0.0, 0.58, 1.0);
323        let easing = Easing::CubicBezier(bezier);
324        assert!((easing.apply(0.0)).abs() < EPSILON);
325        assert!((easing.apply(1.0) - 1.0).abs() < EPSILON);
326    }
327}