1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
use crate::math::MulAdd;
macro_rules! impl_spring {
($name:ident, $ty:ty) => {
/// Critically damped spring — chase a target without overshoot.
///
/// Uses a Padé approximant for stable behavior with variable dt.
/// The output smoothly approaches the target with no ringing.
///
/// # Use Cases
/// - Camera smoothing in games
/// - Smooth parameter transitions
/// - Any "chase this value" without PID complexity
#[derive(Debug, Clone)]
pub struct $name {
smooth_time: $ty,
value: $ty,
velocity: $ty,
initialized: bool,
}
impl $name {
/// Creates a new spring with the given smooth time.
///
/// `smooth_time` controls how quickly the spring converges.
/// Larger = slower, smoother. Smaller = faster, more reactive.
#[inline]
pub fn new(smooth_time: $ty) -> Result<Self, crate::ConfigError> {
#[allow(clippy::neg_cmp_op_on_partial_ord)]
if !(smooth_time > 0.0 as $ty) {
return Err(crate::ConfigError::Invalid("smooth_time must be positive"));
}
Ok(Self {
smooth_time,
value: 0.0 as $ty,
velocity: 0.0 as $ty,
initialized: false,
})
}
/// Updates toward the target. Returns the new value.
///
/// `dt` is the time since the last update, in the same units as `smooth_time`.
#[inline]
#[must_use]
pub fn update(&mut self, target: $ty, dt: $ty) -> $ty {
if !self.initialized {
self.value = target;
self.initialized = true;
return target;
}
// Critically damped spring using Padé approximant
// omega = 2 / smooth_time (natural frequency for critical damping)
let omega = 2.0 as $ty / self.smooth_time;
let x = omega * dt;
// Padé(2,2) approximant to exp(-x): (1 - x/2 + x²/12) / (1 + x/2 + x²/12)
// Simplified: use exact exp(-x) via (1 + x + x²/2)⁻¹ approximation
let exp_neg = 1.0 as $ty / (x.fma(x.fma(0.5 as $ty, 1.0 as $ty), 1.0 as $ty));
let delta = self.value - target;
let temp = (self.velocity + omega * delta) * dt;
self.velocity = (self.velocity - omega * temp) * exp_neg;
self.value = (delta + temp).fma(exp_neg, target);
self.value
}
/// Current output value.
#[inline]
#[must_use]
pub fn value(&self) -> $ty {
self.value
}
/// Current velocity.
#[inline]
#[must_use]
pub fn velocity(&self) -> $ty {
self.velocity
}
/// Resets to uninitialized state.
#[inline]
pub fn reset(&mut self) {
self.value = 0.0 as $ty;
self.velocity = 0.0 as $ty;
self.initialized = false;
}
/// Resets to a specific value with zero velocity.
#[inline]
pub fn reset_to(&mut self, value: $ty) {
self.value = value;
self.velocity = 0.0 as $ty;
self.initialized = true;
}
}
};
}
impl_spring!(SpringF64, f64);
impl_spring!(SpringF32, f32);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn converges_to_target() {
let mut s = SpringF64::new(0.5).unwrap();
let target = 100.0;
for _ in 0..200 {
let _ = s.update(target, 0.016); // ~60fps
}
assert!(
(s.value() - target).abs() < 0.01,
"should converge to {target}, got {}",
s.value()
);
}
#[test]
fn no_overshoot() {
let mut s = SpringF64::new(0.5).unwrap();
let target = 100.0;
let _ = s.update(0.0, 0.016); // initialize at 0
let mut max_value = 0.0f64;
for _ in 0..1000 {
let v = s.update(target, 0.016);
if v > max_value {
max_value = v;
}
}
assert!(
max_value <= target + 0.1,
"should not overshoot, max was {max_value}"
);
}
#[test]
fn variable_dt_stable() {
let mut s = SpringF64::new(1.0).unwrap();
let target = 50.0;
// Large dt steps shouldn't explode
let _ = s.update(target, 0.5);
assert!(s.value().is_finite());
let _ = s.update(target, 2.0);
assert!(s.value().is_finite());
let _ = s.update(target, 10.0);
assert!(s.value().is_finite());
}
#[test]
#[allow(clippy::float_cmp)]
fn reset_to() {
let mut s = SpringF64::new(0.5).unwrap();
let _ = s.update(100.0, 0.016);
s.reset_to(50.0);
assert_eq!(s.value(), 50.0);
assert_eq!(s.velocity(), 0.0);
}
#[test]
fn f32_basic() {
let mut s = SpringF32::new(0.5).unwrap();
let v = s.update(100.0, 0.016);
assert!((v - 100.0).abs() < 0.01);
}
#[test]
fn rejects_zero_smooth_time() {
assert!(matches!(
SpringF64::new(0.0),
Err(crate::ConfigError::Invalid(_))
));
}
}