diff --git a/crates/bevy_math/src/curve/easing.rs b/crates/bevy_math/src/curve/easing.rs new file mode 100644 index 0000000000..fd163291d2 --- /dev/null +++ b/crates/bevy_math/src/curve/easing.rs @@ -0,0 +1,298 @@ +//! Module containing different [`Easing`] curves to control the transition between two values and +//! the [`EasingCurve`] struct to make use of them. + +use crate::{ + ops::{self, FloatPow}, + VectorSpace, +}; + +use super::{Curve, FunctionCurve, Interval}; + +/// A trait for [`Curves`] that map the [unit interval] to some other values. These kinds of curves +/// are used to create a transition between two values. Easing curves are most commonly known from +/// [CSS animations] but are also widely used in other fields. +/// +/// [unit interval]: `Interval::UNIT` +/// [`Curves`]: `Curve` +/// [CSS animations]: https://developer.mozilla.org/en-US/docs/Web/CSS/easing-function +pub trait Easing: Curve {} +impl> Easing for EasingCurve {} +impl Easing for LinearCurve {} +impl Easing for StepCurve {} +impl Easing for ElasticCurve {} + +/// A [`Curve`] that is defined by +/// +/// - an initial `start` sample value at `t = 0` +/// - a final `end` sample value at `t = 1` +/// - an [`EasingCurve`] to interpolate between the two values within the [unit interval]. +/// +/// [unit interval]: `Interval::UNIT` +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "bevy_reflect", derive(bevy_reflect::Reflect))] +pub struct EasingCurve +where + T: VectorSpace, + E: Curve, +{ + start: T, + end: T, + easing: E, +} + +impl Curve for EasingCurve +where + T: VectorSpace, + E: Curve, +{ + #[inline] + fn domain(&self) -> Interval { + Interval::UNIT + } + + #[inline] + fn sample_unchecked(&self, t: f32) -> T { + let domain = self.easing.domain(); + let t = domain.start().lerp(domain.end(), t); + self.start.lerp(self.end, self.easing.sample_unchecked(t)) + } +} + +impl EasingCurve +where + T: VectorSpace, + E: Curve, +{ + /// Create a new [`EasingCurve`] over the [unit interval] which transitions between a `start` + /// and an `end` value based on the provided [`Curve`] curve. + /// + /// If the input curve's domain is not the unit interval, then the [`EasingCurve`] will ensure + /// that this invariant is guaranteed by internally [reparametrizing] the curve to the unit + /// interval. + /// + /// [`Curve`]: `Curve` + /// [unit interval]: `Interval::UNIT` + /// [reparametrizing]: `Curve::reparametrize_linear` + pub fn new(start: T, end: T, easing: E) -> Result { + easing + .domain() + .is_bounded() + .then_some(Self { start, end, easing }) + .ok_or(EasingCurveError) + } +} + +impl EasingCurve f32>> { + /// A [`Curve`] mapping the [unit interval] to itself. + /// + /// Quadratic easing functions can have exactly one critical point. This is a point on the function + /// such that `f′(t) = 0`. This means that there won't be any sudden jumps at this point leading to + /// smooth transitions. A common choice is to place that point at `t = 0` or [`t = 1`]. + /// + /// It uses the function `f(t) = t²` + /// + /// [unit domain]: `Interval::UNIT` + /// [`t = 1`]: `Self::quadratic_ease_out` + pub fn quadratic_ease_in() -> Self { + Self { + start: 0.0, + end: 1.0, + easing: FunctionCurve::new(Interval::UNIT, FloatPow::squared), + } + } + + /// A [`Curve`] mapping the [unit interval] to itself. + /// + /// Quadratic easing functions can have exactly one critical point. This is a point on the function + /// such that `f′(t) = 0`. This means that there won't be any sudden jumps at this point leading to + /// smooth transitions. A common choice is to place that point at [`t = 0`] or`t = 1`. + /// + /// It uses the function `f(t) = 1 - (1 - t)²` + /// + /// [unit domain]: `Interval::UNIT` + /// [`t = 0`]: `Self::quadratic_ease_in` + pub fn quadratic_ease_out() -> Self { + fn f(t: f32) -> f32 { + 1.0 - (1.0 - t).squared() + } + Self { + start: 0.0, + end: 1.0, + easing: FunctionCurve::new(Interval::UNIT, f), + } + } + + /// A [`Curve`] mapping the [unit interval] to itself. + /// + /// Cubic easing functions can have up to two critical points. These are points on the function + /// such that `f′(t) = 0`. This means that there won't be any sudden jumps at these points leading to + /// smooth transitions. For this curve they are placed at `t = 0` and `t = 1` respectively and the + /// result is a well-known kind of [sigmoid function] called a [smoothstep function]. + /// + /// It uses the function `f(t) = t² * (3 - 2t)` + /// + /// [unit domain]: `Interval::UNIT` + /// [sigmoid function]: https://en.wikipedia.org/wiki/Sigmoid_function + /// [smoothstep function]: https://en.wikipedia.org/wiki/Smoothstep + pub fn smoothstep() -> Self { + fn f(t: f32) -> f32 { + t.squared() * (3.0 - 2.0 * t) + } + Self { + start: 0.0, + end: 1.0, + easing: FunctionCurve::new(Interval::UNIT, f), + } + } + + /// A [`Curve`] mapping the [unit interval] to itself. + /// + /// It uses the function `f(t) = t` + /// + /// [unit domain]: `Interval::UNIT` + pub fn identity() -> Self { + Self { + start: 0.0, + end: 1.0, + easing: FunctionCurve::new(Interval::UNIT, core::convert::identity), + } + } +} + +/// An error that occurs if the construction of [`EasingCurve`] fails +#[derive(Debug, thiserror::Error)] +#[error("Easing curves can only be constructed from curves with bounded domain")] +pub struct EasingCurveError; + +/// A [`Curve`] that is defined by a `start` and an `end` point, together with linear interpolation +/// between the values over the [unit interval]. It's basically an [`EasingCurve`] with the +/// identity as an easing function. +/// +/// [unit interval]: `Interval::UNIT` +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "bevy_reflect", derive(bevy_reflect::Reflect))] +pub struct LinearCurve { + start: T, + end: T, +} + +impl Curve for LinearCurve +where + T: VectorSpace, +{ + #[inline] + fn domain(&self) -> Interval { + Interval::UNIT + } + + #[inline] + fn sample_unchecked(&self, t: f32) -> T { + self.start.lerp(self.end, t) + } +} + +impl LinearCurve +where + T: VectorSpace, +{ + /// Create a new [`LinearCurve`] over the [unit interval] from `start` to `end`. + /// + /// [unit interval]: `Interval::UNIT` + pub fn new(start: T, end: T) -> Self { + Self { start, end } + } +} + +/// A [`Curve`] mapping the [unit interval] to itself. +/// +/// This leads to a cruve with sudden jumps at the step points and segments with constant values +/// everywhere else. +/// +/// It uses the function `f(n,t) = round(t * n) / n` +/// +/// parametrized by `n`, the number of jumps +/// +/// - for `n == 0` this is equal to [`constant_curve(Interval::UNIT, 0.0)`] +/// - for `n == 1` this makes a single jump at `t = 0.5`, splitting the interval evenly +/// - for `n >= 2` the curve has a start segment and an end segment of length `1 / (2 * n)` and in +/// between there are `n - 1` segments of length `1 / n` +/// +/// [unit domain]: `Interval::UNIT` +/// [`constant_curve(Interval::UNIT, 0.0)`]: `crate::curve::constant_curve` +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "bevy_reflect", derive(bevy_reflect::Reflect))] +pub struct StepCurve { + num_steps: usize, +} + +impl Curve for StepCurve { + #[inline] + fn domain(&self) -> Interval { + Interval::UNIT + } + + #[inline] + fn sample_unchecked(&self, t: f32) -> f32 { + if t != 0.0 || t != 1.0 { + (t * self.num_steps as f32).round() / self.num_steps.max(1) as f32 + } else { + t + } + } +} + +impl StepCurve { + /// Create a new [`StepCurve`] over the [unit interval] which makes the given amount of steps. + /// + /// [unit interval]: `Interval::UNIT` + pub fn new(num_steps: usize) -> Self { + Self { num_steps } + } +} + +/// A [`Curve`] over the [unit interval]. +/// +/// This class of easing functions is derived as an approximation of a [spring-mass-system] +/// solution. +/// +/// - For `ω → 0` the curve converges to the [smoothstep function] +/// - For `ω → ∞` the curve gets increasingly more bouncy +/// +/// It uses the function `f(omega,t) = 1 - (1 - t)²(2sin(omega * t) / omega + cos(omega * t))` +/// +/// parametrized by `omega` +/// +/// [unit domain]: `Interval::UNIT` +/// [smoothstep function]: https://en.wikipedia.org/wiki/Smoothstep +/// [spring-mass-system]: https://notes.yvt.jp/Graphics/Easing-Functions/#elastic-easing +#[derive(Clone, Debug)] +#[cfg_attr(feature = "serialize", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr(feature = "bevy_reflect", derive(bevy_reflect::Reflect))] +pub struct ElasticCurve { + omega: f32, +} + +impl Curve for ElasticCurve { + #[inline] + fn domain(&self) -> Interval { + Interval::UNIT + } + + #[inline] + fn sample_unchecked(&self, t: f32) -> f32 { + 1.0 - (1.0 - t).squared() + * (2.0 * ops::sin(self.omega * t) / self.omega + ops::cos(self.omega * t)) + } +} + +impl ElasticCurve { + /// Create a new [`ElasticCurve`] over the [unit interval] with the given parameter `omega`. + /// + /// [unit interval]: `Interval::UNIT` + pub fn new(omega: f32) -> Self { + Self { omega } + } +} diff --git a/crates/bevy_math/src/curve/interval.rs b/crates/bevy_math/src/curve/interval.rs index ae651b52cf..244fd1b4c4 100644 --- a/crates/bevy_math/src/curve/interval.rs +++ b/crates/bevy_math/src/curve/interval.rs @@ -65,7 +65,7 @@ impl Interval { } } - /// The unit interval covering the range between `0.0` and `1.0`. + /// An interval of length 1.0, starting at 0.0 and ending at 1.0. pub const UNIT: Self = Self { start: 0.0, end: 1.0, diff --git a/crates/bevy_math/src/curve/mod.rs b/crates/bevy_math/src/curve/mod.rs index 1a24266a3e..9a1bb946c6 100644 --- a/crates/bevy_math/src/curve/mod.rs +++ b/crates/bevy_math/src/curve/mod.rs @@ -4,6 +4,7 @@ pub mod adaptors; pub mod cores; +pub mod easing; pub mod interval; pub mod iterable; pub mod sample_curves; @@ -712,10 +713,12 @@ where #[cfg(test)] mod tests { + use super::easing::*; use super::*; use crate::{ops, Quat}; use approx::{assert_abs_diff_eq, AbsDiffEq}; use core::f32::consts::TAU; + use glam::*; #[test] fn curve_can_be_made_into_an_object() { @@ -748,6 +751,97 @@ mod tests { assert!(curve.sample(-1.0).is_none()); } + #[test] + fn linear_curve() { + let start = Vec2::ZERO; + let end = Vec2::new(1.0, 2.0); + let curve = LinearCurve::new(start, end); + + let mid = (start + end) / 2.0; + + [(0.0, start), (0.5, mid), (1.0, end)] + .into_iter() + .for_each(|(t, x)| { + assert!(curve.sample_unchecked(t).abs_diff_eq(x, f32::EPSILON)); + }); + } + + #[test] + fn easing_curves_step() { + let start = Vec2::ZERO; + let end = Vec2::new(1.0, 2.0); + + let curve = EasingCurve::new(start, end, StepCurve::new(4)).unwrap(); + [ + (0.0, start), + (0.124, start), + (0.125, Vec2::new(0.25, 0.5)), + (0.374, Vec2::new(0.25, 0.5)), + (0.375, Vec2::new(0.5, 1.0)), + (0.624, Vec2::new(0.5, 1.0)), + (0.625, Vec2::new(0.75, 1.5)), + (0.874, Vec2::new(0.75, 1.5)), + (0.875, end), + (1.0, end), + ] + .into_iter() + .for_each(|(t, x)| { + assert!(curve.sample_unchecked(t).abs_diff_eq(x, f32::EPSILON)); + }); + } + + #[test] + fn easing_curves_quadratic() { + let start = Vec2::ZERO; + let end = Vec2::new(1.0, 2.0); + + let curve = EasingCurve::new(start, end, EasingCurve::quadratic_ease_in()).unwrap(); + [ + (0.0, start), + (0.25, Vec2::new(0.0625, 0.125)), + (0.5, Vec2::new(0.25, 0.5)), + (1.0, end), + ] + .into_iter() + .for_each(|(t, x)| { + assert!(curve.sample_unchecked(t).abs_diff_eq(x, f32::EPSILON),); + }); + } + + #[test] + fn easing_curve_non_unit_domain() { + let start = Vec2::ZERO; + let end = Vec2::new(1.0, 2.0); + + // even though the quadratic_ease_in input curve has the domain [0.0, 2.0], the easing + // curve correctly behaves as if its domain were [0.0, 1.0] + let curve = EasingCurve::new( + start, + end, + EasingCurve::quadratic_ease_in() + .reparametrize(Interval::new(0.0, 2.0).unwrap(), |t| t / 2.0), + ) + .unwrap(); + + [ + (-0.1, None), + (0.0, Some(start)), + (0.25, Some(Vec2::new(0.0625, 0.125))), + (0.5, Some(Vec2::new(0.25, 0.5))), + (1.0, Some(end)), + (1.1, None), + ] + .into_iter() + .for_each(|(t, x)| { + let sample = curve.sample(t); + match (sample, x) { + (None, None) => assert_eq!(sample, x), + (Some(s), Some(x)) => assert!(s.abs_diff_eq(x, f32::EPSILON)), + _ => unreachable!(), + }; + }); + } + #[test] fn mapping() { let curve = function_curve(Interval::EVERYWHERE, |t| t * 3.0 + 1.0);