#[derive(Clone, Copy, Debug)]
pub struct SpringConfig {
pub mass: f32,
pub stiffness: f32,
pub damping: f32,
}
impl SpringConfig {
pub const DEFAULT: Self = Self {
mass: 1.0,
stiffness: 500.0,
damping: 29.5,
};
pub const BOUNCY: Self = Self {
mass: 1.0,
stiffness: 400.0,
damping: 19.6,
};
pub const SNAPPY: Self = Self {
mass: 1.0,
stiffness: 900.0,
damping: 43.0,
};
pub const GENTLE: Self = Self {
mass: 1.0,
stiffness: 250.0,
damping: 24.7,
};
pub fn peak_overshoot(&self) -> f32 {
let denominator = 2.0 * (self.mass * self.stiffness).sqrt();
if denominator <= 0.0 {
return 0.0;
}
let zeta = self.damping / denominator;
if !zeta.is_finite() {
return 0.0;
}
if zeta < 0.0 {
return 0.0;
}
if zeta == 0.0 {
return 1.0;
}
if zeta >= 1.0 {
return 0.0;
}
(-std::f32::consts::PI * zeta / (1.0 - zeta * zeta).sqrt()).exp()
}
}
#[derive(Clone, Debug)]
pub struct SpringState {
pub position: f32,
pub velocity: f32,
pub last_t: f32,
}
impl SpringState {
pub fn new() -> Self {
Self::moving_at(0.0)
}
pub fn moving_at(velocity: f32) -> Self {
Self {
position: 0.0,
velocity,
last_t: 0.0,
}
}
pub fn step(&mut self, elapsed_secs: f32, config: &SpringConfig) -> f32 {
let dt = (elapsed_secs - self.last_t).max(0.0);
self.last_t = elapsed_secs;
if dt < 1e-6 {
return self.position;
}
let target = 1.0;
let max_dt = 0.033;
let capped_dt = dt.min(max_dt);
let displacement = self.position - target;
let spring_force = -config.stiffness * displacement;
let damping_force = -config.damping * self.velocity;
let force = spring_force + damping_force;
let acceleration = force / config.mass;
self.velocity += acceleration * capped_dt;
self.position += self.velocity * capped_dt;
self.position
}
pub fn is_settled(&self, threshold: f32) -> bool {
(self.position - 1.0).abs() < threshold && self.velocity.abs() < threshold
}
}
impl Default for SpringState {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_impossible_spring_reports_no_overshoot_rather_than_nan() {
for config in [
SpringConfig {
mass: -1.0,
stiffness: 400.0,
damping: 20.0,
},
SpringConfig {
mass: 1.0,
stiffness: -400.0,
damping: 20.0,
},
SpringConfig {
mass: f32::NAN,
stiffness: 400.0,
damping: 20.0,
},
] {
let overshoot = config.peak_overshoot();
assert!(
overshoot.is_finite(),
"{config:?} reported {overshoot}, which would poison a damage rect"
);
}
}
#[test]
fn test_spring_reaches_target() {
let mut state = SpringState::new();
let config = SpringConfig::DEFAULT;
let mut position = 0.0;
for i in 0..120 {
let elapsed_secs = i as f32 / 60.0; position = state.step(elapsed_secs, &config);
}
assert!(
(position - 1.0).abs() < 0.1,
"Spring should settle near target, got {}",
position
);
}
#[test]
fn test_spring_overshoots() {
let mut state = SpringState::new();
let config = SpringConfig::BOUNCY;
let mut max_position: f32 = 0.0;
for i in 0..120 {
let elapsed_secs = i as f32 / 60.0; let pos = state.step(elapsed_secs, &config);
max_position = max_position.max(pos);
}
assert!(
max_position > 1.0,
"Bouncy spring should overshoot, max was {}",
max_position
);
}
}
#[cfg(test)]
mod overshoot_tests {
use super::*;
#[test]
fn the_presets_overshoot_by_what_they_say() {
let pairs = [
(SpringConfig::DEFAULT, 0.06),
(SpringConfig::BOUNCY, 0.17),
(SpringConfig::SNAPPY, 0.04),
(SpringConfig::GENTLE, 0.03),
];
for (config, documented) in pairs {
let computed = config.peak_overshoot();
assert!(
(computed - documented).abs() < 0.015,
"{config:?} documents {documented} and computes {computed}"
);
}
}
#[test]
fn an_undamped_spring_reports_a_full_overshoot() {
let undamped = SpringConfig {
mass: 1.0,
stiffness: 100.0,
damping: 0.0,
};
assert_eq!(undamped.peak_overshoot(), 1.0);
let nearly = SpringConfig {
damping: 0.02,
..undamped
};
assert!(nearly.peak_overshoot() > 0.99);
}
#[test]
fn a_spring_given_velocity_overshoots_more_than_one_at_rest() {
let config = SpringConfig::BOUNCY;
let reported = 1.0 + config.peak_overshoot();
let peak_of = |initial_velocity: f32| {
let mut state = SpringState::moving_at(initial_velocity);
let mut elapsed = 0.0f32;
let mut peak = 0.0f32;
for _ in 0..4000 {
elapsed += 1.0 / 480.0;
peak = peak.max(state.step(elapsed, &config));
}
peak
};
assert!(peak_of(0.0) <= reported + 0.01, "from rest it holds");
let carried = peak_of(16.0);
assert!(
carried > reported + 0.05,
"and carrying velocity it does not: {carried} against {reported}"
);
}
#[test]
fn a_spring_that_cannot_bounce_reports_nothing() {
let critical = SpringConfig {
mass: 1.0,
stiffness: 100.0,
damping: 20.0,
};
assert_eq!(critical.peak_overshoot(), 0.0);
let overdamped = SpringConfig {
mass: 1.0,
stiffness: 100.0,
damping: 40.0,
};
assert_eq!(overdamped.peak_overshoot(), 0.0);
}
#[test]
fn the_simulation_stays_within_the_reported_peak() {
for config in [SpringConfig::DEFAULT, SpringConfig::BOUNCY] {
let mut state = SpringState::new();
let mut peak = 0.0f32;
let mut elapsed = 0.0f32;
for _ in 0..2000 {
elapsed += 1.0 / 240.0;
peak = peak.max(state.step(elapsed, &config));
}
let bound = 1.0 + config.peak_overshoot();
assert!(
peak <= bound + 0.01 && peak > 1.0,
"{config:?} peaked at {peak}, bound {bound}"
);
}
}
}