pub type Easing = fn(f32) -> f32;
pub fn linear(t: f32) -> f32 {
t
}
pub fn smoothstep(t: f32) -> f32 {
t * t * (3.0 - 2.0 * t)
}
pub fn ease_out_cubic(t: f32) -> f32 {
let u = 1.0 - t;
1.0 - u * u * u
}
pub fn ease_in_cubic(t: f32) -> f32 {
t * t * t
}
pub fn ease_in_quad(t: f32) -> f32 {
t * t
}
pub fn ease_in_out_quad(t: f32) -> f32 {
if t < 0.5 {
2.0 * t * t
} else {
let u = -2.0 * t + 2.0;
1.0 - u * u / 2.0
}
}
#[derive(Debug, Clone)]
pub struct ErrorSmoother<State> {
from: Option<State>,
elapsed: f32,
duration: f32,
easing: Easing,
}
impl<State: Clone> ErrorSmoother<State> {
pub fn new(duration_secs: f32) -> Self {
Self {
from: None,
elapsed: 0.0,
duration: duration_secs.max(0.0),
easing: linear,
}
}
pub fn with_easing(mut self, easing: Easing) -> Self {
self.easing = easing;
self
}
pub fn begin_from(&mut self, rendered_before_correction: State) {
if self.duration <= 0.0 {
self.from = None;
return;
}
self.from = Some(rendered_before_correction);
self.elapsed = 0.0;
}
pub fn advance(&mut self, dt_secs: f32) {
if self.from.is_none() {
return;
}
self.elapsed += dt_secs;
if self.elapsed >= self.duration {
self.from = None;
}
}
pub fn sample(&self, logical: &State, lerp: impl Fn(&State, &State, f32) -> State) -> State {
match &self.from {
Some(from) if self.duration > 0.0 => {
let progress = (self.elapsed / self.duration).clamp(0.0, 1.0);
let t = (self.easing)(progress);
lerp(from, logical, t)
}
_ => logical.clone(),
}
}
pub fn reset(&mut self) {
self.from = None;
self.elapsed = 0.0;
}
pub fn is_easing(&self) -> bool {
self.from.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Clone, Copy, Debug, PartialEq)]
struct P(f32);
fn lerp(a: &P, b: &P, t: f32) -> P {
P(a.0 + (b.0 - a.0) * t)
}
#[test]
fn without_a_correction_it_returns_the_logical_state_unchanged() {
let s = ErrorSmoother::<P>::new(0.1);
assert_eq!(s.sample(&P(42.0), lerp), P(42.0));
assert!(!s.is_easing());
}
#[test]
fn an_ease_starts_at_the_old_position_and_ends_at_the_logical_one() {
let mut s = ErrorSmoother::new(0.1);
s.begin_from(P(0.0)); assert_eq!(s.sample(&P(10.0), lerp), P(0.0), "starts where the eye was");
s.advance(0.05); assert_eq!(s.sample(&P(10.0), lerp), P(5.0), "half caught up");
s.advance(0.05); assert_eq!(s.sample(&P(10.0), lerp), P(10.0), "arrived at the logical state");
assert!(!s.is_easing(), "ease finished");
}
#[test]
fn the_ease_tracks_a_logical_state_that_keeps_moving() {
let mut s = ErrorSmoother::new(0.1);
s.begin_from(P(0.0));
s.advance(0.05); assert_eq!(s.sample(&P(20.0), lerp), P(10.0));
}
#[test]
fn zero_duration_never_eases() {
let mut s = ErrorSmoother::new(0.0);
s.begin_from(P(0.0));
assert!(!s.is_easing(), "a zero duration disables smoothing");
assert_eq!(s.sample(&P(9.0), lerp), P(9.0), "renders the logical state at once");
}
#[test]
fn a_new_correction_mid_ease_restarts_from_the_new_point() {
let mut s = ErrorSmoother::new(0.1);
s.begin_from(P(0.0));
s.advance(0.05);
s.begin_from(P(100.0)); assert_eq!(s.sample(&P(200.0), lerp), P(100.0), "eases from the newest point");
}
#[test]
fn the_easing_curves_pin_their_endpoints() {
for curve in [linear, smoothstep, ease_out_cubic, ease_in_cubic, ease_in_quad, ease_in_out_quad] {
assert!((curve(0.0) - 0.0).abs() < 1e-6, "starts at 0");
assert!((curve(1.0) - 1.0).abs() < 1e-6, "ends at 1");
}
assert!((smoothstep(0.5) - 0.5).abs() < 1e-6);
assert!((ease_in_out_quad(0.5) - 0.5).abs() < 1e-6);
for t in [0.0f32, 0.25, 0.5, 0.75, 1.0] {
assert!((ease_in_cubic(t) - (1.0 - ease_out_cubic(1.0 - t))).abs() < 1e-6, "mirror at {t}");
}
assert!(ease_in_cubic(0.5) < 0.5, "ease-in is behind linear at the midpoint");
assert!(ease_in_cubic(0.5) < ease_in_quad(0.5) * 0.6, "cubic is much lazier early than quad");
}
#[test]
fn a_swapped_easing_reshapes_the_ease_without_touching_the_endpoints() {
let mut s = ErrorSmoother::new(0.1).with_easing(smoothstep);
s.begin_from(P(0.0));
assert_eq!(s.sample(&P(10.0), lerp), P(0.0), "still starts where the eye was");
s.advance(0.025); let eased = s.sample(&P(10.0), lerp).0;
assert!((eased - 1.5625).abs() < 1e-4, "smoothstep eases in slower than linear (2.5), got {eased}");
s.advance(0.075); assert_eq!(s.sample(&P(10.0), lerp), P(10.0), "still arrives at the logical state");
}
#[test]
fn the_default_easing_is_linear() {
let mut s = ErrorSmoother::new(0.1);
s.begin_from(P(0.0));
s.advance(0.03); assert!((s.sample(&P(10.0), lerp).0 - 3.0).abs() < 1e-5, "default is a straight line");
}
}