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}