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,
retain: Option<f32>,
remaining: f32,
}
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,
retain: None,
remaining: 0.0,
}
}
pub fn at_rate(retain_per_frame: f32) -> Self {
Self {
from: None,
elapsed: 0.0,
duration: 0.0,
easing: linear,
retain: Some(retain_per_frame.clamp(0.0, 0.999)),
remaining: 0.0,
}
}
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.retain.is_none() && self.duration <= 0.0 {
self.from = None;
return;
}
self.from = Some(rendered_before_correction);
self.elapsed = 0.0;
self.remaining = 1.0;
}
pub fn advance(&mut self, dt_secs: f32) {
if self.from.is_none() {
return;
}
if let Some(retain) = self.retain {
self.remaining *= retain.powf(dt_secs * 60.0);
if self.remaining < 1e-3 {
self.from = 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.retain.is_some() => lerp(from, logical, 1.0 - self.remaining),
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;
self.remaining = 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 a_rate_ease_starts_where_it_was_and_reaches_the_logical_state() {
let mut s: ErrorSmoother<P> = ErrorSmoother::at_rate(0.85);
s.begin_from(P(0.0));
assert_eq!(s.sample(&P(10.0), lerp).0, 0.0, "the first frame still draws the old place");
for _ in 0..200 {
s.advance(1.0 / 60.0);
}
assert!(!s.is_easing(), "a rate needs a floor or it never finishes");
assert_eq!(s.sample(&P(10.0), lerp).0, 10.0);
}
#[test]
fn a_rate_ease_survives_corrections_faster_than_it_finishes() {
let mut s: ErrorSmoother<P> = ErrorSmoother::at_rate(0.85);
let mut drawn = 0.0f32;
let logical = P(10.0);
for _ in 0..200 {
s.begin_from(P(drawn));
s.advance(1.0 / 60.0);
drawn = s.sample(&logical, lerp).0;
}
assert!(drawn > 9.0, "it should still converge under constant disturbance, got {drawn}");
}
#[test]
fn a_faster_rate_closes_sooner() {
let frames = |retain: f32| {
let mut s: ErrorSmoother<P> = ErrorSmoother::at_rate(retain);
s.begin_from(P(0.0));
let mut n = 0;
while s.is_easing() && n < 10_000 {
s.advance(1.0 / 60.0);
n += 1;
}
n
};
assert!(frames(0.80) < frames(0.95));
}
#[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");
}
}
#[derive(Debug, Clone, Copy)]
pub struct AdaptiveDecay {
small: f32,
large: f32,
small_at: f32,
large_at: f32,
}
impl Default for AdaptiveDecay {
fn default() -> Self {
Self {
small: 0.95,
large: 0.85,
small_at: 0.25,
large_at: 1.0,
}
}
}
impl AdaptiveDecay {
pub fn new(small: f32, large: f32, small_at: f32, large_at: f32) -> Self {
Self {
small,
large,
small_at,
large_at,
}
}
pub fn retain(&self, magnitude: f32, dt_secs: f32) -> f32 {
let span = (self.large_at - self.small_at).max(f32::EPSILON);
let t = ((magnitude - self.small_at) / span).clamp(0.0, 1.0);
let per_frame = self.small + (self.large - self.small) * t;
per_frame.powf(dt_secs * 60.0)
}
}
#[cfg(test)]
mod adaptive_tests {
use super::*;
#[test]
fn a_large_error_clears_sooner_than_a_small_one() {
let decay = AdaptiveDecay::default();
let dt = 1.0 / 60.0;
let frames_to_clear = |start: f32| {
let mut error = start;
let mut frames = 0;
while error > start / 20.0 && frames < 10_000 {
error *= decay.retain(error, dt);
frames += 1;
}
frames
};
assert!(
frames_to_clear(2.0) < frames_to_clear(0.05),
"{} frames for a large error, {} for a small one",
frames_to_clear(2.0),
frames_to_clear(0.05)
);
}
#[test]
fn the_same_wall_time_sheds_the_same_error_at_any_framerate() {
let decay = AdaptiveDecay::default();
let magnitude = 0.5;
let after = |fps: f32| {
let dt = 1.0 / fps;
let mut kept = 1.0f32;
for _ in 0..(fps as usize) {
kept *= decay.retain(magnitude, dt);
}
kept
};
assert!((after(30.0) - after(60.0)).abs() < 1e-4, "{} vs {}", after(30.0), after(60.0));
assert!((after(144.0) - after(60.0)).abs() < 1e-4);
}
#[test]
fn magnitudes_outside_the_band_clamp() {
let decay = AdaptiveDecay::default();
let dt = 1.0 / 60.0;
assert!((decay.retain(0.0, dt) - 0.95).abs() < 1e-5);
assert!((decay.retain(1000.0, dt) - 0.85).abs() < 1e-5);
}
#[test]
fn an_error_that_is_kept_entirely_never_moves() {
let flat = AdaptiveDecay::new(0.9, 0.9, 0.0, 1.0);
let dt = 1.0 / 60.0;
assert!((flat.retain(0.01, dt) - flat.retain(5.0, dt)).abs() < 1e-6);
}
}