Skip to main content

viewport_lib/runtime/
timestep.rs

1//! Fixed timestep accumulator for physics and simulation.
2//!
3//! Call [`FixedTimestep::advance`] each frame with the wall-clock delta. Iterate
4//! the returned [`FixedStepIter`] to consume as many fixed steps as the
5//! accumulator has filled. After iteration, [`FixedTimestep::alpha`] gives the
6//! blend factor for transform interpolation when rendering between steps.
7//!
8//! # Example
9//!
10//! ```rust
11//! use viewport_lib::runtime::FixedTimestep;
12//!
13//! let mut ts = FixedTimestep::new(60.0);
14//!
15//! let wall_dt = 0.016_f32;
16//! for step_dt in ts.advance(wall_dt) {
17//!     // run physics for step_dt seconds
18//!     let _ = step_dt;
19//! }
20//! let alpha = ts.alpha(); // use this to lerp between previous and current transforms
21//! ```
22
23/// Accumulates wall-clock time and yields fixed simulation steps.
24///
25/// The step size is `1.0 / hz` seconds. The accumulator drains by `step_dt`
26/// for each step yielded. A long frame yields multiple steps; a short frame
27/// yields zero.
28#[derive(Debug, Clone)]
29#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
30pub struct FixedTimestep {
31    /// Duration of each fixed step in seconds (`1.0 / hz`).
32    pub step_dt: f32,
33    accumulator: f32,
34}
35
36impl FixedTimestep {
37    /// Create a new accumulator that yields steps at `hz` Hz.
38    ///
39    /// Panics if `hz` is not positive and finite.
40    pub fn new(hz: f32) -> Self {
41        assert!(hz > 0.0 && hz.is_finite(), "hz must be positive and finite");
42        Self {
43            step_dt: 1.0 / hz,
44            accumulator: 0.0,
45        }
46    }
47
48    /// Add `dt` seconds to the accumulator and return an iterator over ready steps.
49    ///
50    /// Each call to `next()` on the returned iterator drains one step from the
51    /// accumulator and yields `self.step_dt`. Exhaust the iterator before calling
52    /// [`alpha`](Self::alpha) so the blend factor reflects the remaining leftover.
53    pub fn advance(&mut self, dt: f32) -> FixedStepIter<'_> {
54        self.accumulator += dt;
55        FixedStepIter { ts: self }
56    }
57
58    /// Blend factor for rendering interpolation: `accumulator / step_dt`.
59    ///
60    /// Ranges from `0.0` (step just completed) to just under `1.0` (about to
61    /// step). Use this to lerp between the previous and current physics transform
62    /// when rendering between fixed steps. Clamped to `[0.0, 1.0]`.
63    ///
64    /// Call this after exhausting the iterator from [`advance`](Self::advance).
65    pub fn alpha(&self) -> f32 {
66        (self.accumulator / self.step_dt).clamp(0.0, 1.0)
67    }
68
69    /// Reset the accumulator to zero without changing the step rate.
70    pub fn reset(&mut self) {
71        self.accumulator = 0.0;
72    }
73}
74
75/// Iterator over pending fixed simulation steps.
76///
77/// Returned by [`FixedTimestep::advance`]. Each `next()` drains one step from
78/// the accumulator and yields the step duration in seconds.
79pub struct FixedStepIter<'a> {
80    ts: &'a mut FixedTimestep,
81}
82
83impl<'a> Iterator for FixedStepIter<'a> {
84    type Item = f32;
85
86    fn next(&mut self) -> Option<f32> {
87        if self.ts.accumulator >= self.ts.step_dt {
88            self.ts.accumulator -= self.ts.step_dt;
89            Some(self.ts.step_dt)
90        } else {
91            None
92        }
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn test_no_steps_on_short_frame() {
102        let mut ts = FixedTimestep::new(60.0);
103        let steps: Vec<_> = ts.advance(0.005).collect();
104        assert!(steps.is_empty());
105    }
106
107    #[test]
108    fn test_one_step_on_normal_frame() {
109        let mut ts = FixedTimestep::new(60.0);
110        let steps: Vec<_> = ts.advance(1.0 / 60.0).collect();
111        assert_eq!(steps.len(), 1);
112        assert!((steps[0] - 1.0 / 60.0).abs() < 1e-6);
113    }
114
115    #[test]
116    fn test_multiple_steps_on_long_frame() {
117        let mut ts = FixedTimestep::new(60.0);
118        // Use an exact multiple of step_dt to avoid f32 rounding.
119        let steps: Vec<_> = ts.advance(ts.step_dt * 3.0 + 0.0001).collect();
120        assert_eq!(steps.len(), 3);
121    }
122
123    #[test]
124    fn test_alpha_between_zero_and_one_after_step() {
125        let mut ts = FixedTimestep::new(60.0);
126        let step_dt = 1.0 / 60.0;
127        // Advance slightly more than one step so there is leftover.
128        ts.advance(step_dt + 0.004).for_each(drop);
129        let alpha = ts.alpha();
130        assert!(alpha > 0.0 && alpha < 1.0, "alpha was {alpha}");
131    }
132
133    #[test]
134    fn test_accumulator_carries_over_across_frames() {
135        let mut ts = FixedTimestep::new(60.0);
136        let step_dt = 1.0 / 60.0;
137        // First frame: half a step.
138        ts.advance(step_dt * 0.5).for_each(drop);
139        // Second frame: another half step. Should now yield one full step.
140        let steps: Vec<_> = ts.advance(step_dt * 0.5).collect();
141        assert_eq!(steps.len(), 1);
142    }
143
144    #[test]
145    fn test_reset_clears_accumulator() {
146        let mut ts = FixedTimestep::new(60.0);
147        ts.advance(0.5).for_each(drop);
148        ts.reset();
149        assert_eq!(ts.alpha(), 0.0);
150        let steps: Vec<_> = ts.advance(0.0).collect();
151        assert!(steps.is_empty());
152    }
153
154    #[test]
155    #[should_panic]
156    fn test_new_panics_on_zero_hz() {
157        FixedTimestep::new(0.0);
158    }
159
160    #[test]
161    #[should_panic]
162    fn test_new_panics_on_negative_hz() {
163        FixedTimestep::new(-60.0);
164    }
165}