Skip to main content

retroglyph_core/frames/
clock.rs

1//! Fixed-timestep accumulator.
2//!
3//! `FrameClock` decouples logic updates (a stable, fixed rate) from rendering
4//! (as fast as the display allows). It is a *pure accumulator*: it never reads a
5//! clock itself. The driver supplies elapsed wall time via
6//! [`Frame::delta`](crate::app::Frame), which keeps `FrameClock` `no_std`-clean and
7//! platform-agnostic (including wasm, where there is no `std::time::Instant`).
8//!
9//! # Example
10//!
11//! ```
12//! use core::time::Duration;
13//! use retroglyph_core::frames::FrameClock;
14//!
15//! let mut clock = FrameClock::new(100); // 100 logic updates per second (10 ms)
16//!
17//! // Once per rendered frame, feed the elapsed time then drain pending steps:
18//! clock.advance(Duration::from_millis(35));
19//! let mut steps = 0;
20//! while clock.tick() {
21//!     steps += 1; // run one fixed logic update
22//! }
23//! assert_eq!(steps, 3); // 35 ms at 100 Hz = 3 whole steps (5 ms remainder)
24//! ```
25
26use core::time::Duration;
27
28/// A fixed-timestep accumulator.
29///
30/// Feed elapsed wall time with [`advance`](Self::advance), then call
31/// [`tick`](Self::tick) in a loop to drain whole logic steps. Use
32/// [`alpha`](Self::alpha) to interpolate rendering between logic frames.
33///
34/// See the `08_animation` example for `FrameClock` in action:
35/// <https://main.retroglyph.dev/examples/08_animation/terminal/>.
36#[derive(Debug, Clone)]
37pub struct FrameClock {
38    step: Duration,
39    accumulator: Duration,
40    max_accumulate: Duration,
41}
42
43impl FrameClock {
44    /// Create an accumulator targeting `hz` logic updates per second.
45    ///
46    /// Catch-up is capped at five steps per frame to avoid a "spiral of death"
47    /// when logic temporarily runs slower than real time.
48    ///
49    /// `hz` above roughly 1e9 rounds `1.0 / hz` below `Duration`'s 1ns resolution; the step is
50    /// floored at 1ns instead of letting it round down to zero, which would otherwise make
51    /// [`tick`](Self::tick) return `true` forever (nothing is ever deducted from the accumulator)
52    /// and [`alpha`](Self::alpha) divide by zero.
53    ///
54    /// # Panics
55    ///
56    /// Panics if `hz` is zero.
57    #[must_use]
58    pub fn new(hz: u32) -> Self {
59        assert!(hz > 0, "FrameClock hz must be non-zero");
60        let step = Duration::from_secs_f64(1.0 / f64::from(hz)).max(Duration::from_nanos(1));
61        Self {
62            step,
63            accumulator: Duration::ZERO,
64            max_accumulate: step * 5,
65        }
66    }
67
68    /// The fixed timestep duration.
69    #[must_use]
70    pub const fn step(&self) -> Duration {
71        self.step
72    }
73
74    /// Add elapsed wall time to the accumulator, clamped to the catch-up cap.
75    ///
76    /// Call once per rendered frame with [`Frame::delta`](crate::app::Frame).
77    pub fn advance(&mut self, dt: Duration) {
78        self.accumulator = (self.accumulator + dt).min(self.max_accumulate);
79    }
80
81    /// Consume one fixed step if enough time has accumulated.
82    ///
83    /// Returns `true` when a logic step is due (and deducts it). Call in a loop
84    /// until it returns `false`, then render:
85    ///
86    /// ```
87    /// # use core::time::Duration;
88    /// # use retroglyph_core::frames::FrameClock;
89    /// # let mut clock = FrameClock::new(60);
90    /// clock.advance(Duration::from_millis(16));
91    /// while clock.tick() {
92    ///     // one fixed logic update
93    /// }
94    /// ```
95    #[must_use]
96    pub fn tick(&mut self) -> bool {
97        if self.accumulator >= self.step {
98            self.accumulator -= self.step;
99            true
100        } else {
101            false
102        }
103    }
104
105    /// Fraction of the next step already accumulated, in `0.0..1.0`.
106    ///
107    /// Multiply by the delta between the previous and current state to render an
108    /// interpolated position between fixed logic frames.
109    #[must_use]
110    pub fn alpha(&self) -> f64 {
111        self.accumulator.as_secs_f64() / self.step.as_secs_f64()
112    }
113
114    /// Reset the accumulator. Call after a pause to avoid a burst of catch-up
115    /// steps on the next frame.
116    pub const fn reset(&mut self) {
117        self.accumulator = Duration::ZERO;
118    }
119}
120
121#[cfg(test)]
122mod tests {
123    use super::*;
124
125    #[test]
126    fn drains_expected_steps() {
127        let mut clock = FrameClock::new(100); // 10 ms per step
128        clock.advance(Duration::from_millis(35));
129        let mut steps = 0;
130        while clock.tick() {
131            steps += 1;
132        }
133        assert_eq!(steps, 3);
134        // 5 ms of remainder carries over as alpha.
135        assert!((clock.alpha() - 0.5).abs() < 1e-6);
136    }
137
138    #[test]
139    fn caps_catch_up() {
140        let mut clock = FrameClock::new(60);
141        // A huge stall must not produce unbounded steps.
142        clock.advance(Duration::from_secs(10));
143        let mut steps = 0;
144        while clock.tick() {
145            steps += 1;
146        }
147        assert_eq!(steps, 5); // clamped to max_accumulate (5 steps)
148    }
149
150    #[test]
151    fn a_huge_hz_does_not_produce_a_zero_step() {
152        // retroglyph#729: `1.0 / hz` used to round below `Duration`'s 1ns resolution for `hz`
153        // above ~1e9, giving a zero step that made `tick()` loop forever and `alpha()` return NaN.
154        let mut clock = FrameClock::new(u32::MAX);
155        assert!(clock.step() > Duration::ZERO);
156        clock.advance(Duration::from_millis(1));
157        let mut steps = 0;
158        while clock.tick() {
159            steps += 1;
160            assert!(steps < 10_000_000, "tick() did not terminate");
161        }
162        assert!(clock.alpha().is_finite());
163    }
164
165    #[test]
166    fn reset_clears_accumulator() {
167        let mut clock = FrameClock::new(60);
168        clock.advance(Duration::from_millis(100));
169        clock.reset();
170        assert!(!clock.tick());
171    }
172}