Skip to main content

retroglyph_core/
frame_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::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::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#[derive(Debug, Clone)]
34pub struct FrameClock {
35    step: Duration,
36    accumulator: Duration,
37    max_accumulate: Duration,
38}
39
40impl FrameClock {
41    /// Create an accumulator targeting `hz` logic updates per second.
42    ///
43    /// Catch-up is capped at five steps per frame to avoid a "spiral of death"
44    /// when logic temporarily runs slower than real time.
45    ///
46    /// # Panics
47    ///
48    /// Panics if `hz` is zero.
49    #[must_use]
50    pub fn new(hz: u32) -> Self {
51        assert!(hz > 0, "FrameClock hz must be non-zero");
52        let step = Duration::from_secs_f64(1.0 / f64::from(hz));
53        Self {
54            step,
55            accumulator: Duration::ZERO,
56            max_accumulate: step * 5,
57        }
58    }
59
60    /// The fixed timestep duration.
61    #[must_use]
62    pub const fn step(&self) -> Duration {
63        self.step
64    }
65
66    /// The fixed timestep duration in seconds.
67    #[must_use]
68    pub const fn dt_secs(&self) -> f64 {
69        self.step.as_secs_f64()
70    }
71
72    /// Add elapsed wall time to the accumulator, clamped to the catch-up cap.
73    ///
74    /// Call once per rendered frame with [`Frame::delta`](crate::Frame).
75    pub fn advance(&mut self, dt: Duration) {
76        self.accumulator = (self.accumulator + dt).min(self.max_accumulate);
77    }
78
79    /// Consume one fixed step if enough time has accumulated.
80    ///
81    /// Returns `true` when a logic step is due (and deducts it). Call in a loop
82    /// until it returns `false`, then render:
83    ///
84    /// ```
85    /// # use core::time::Duration;
86    /// # use retroglyph_core::FrameClock;
87    /// # let mut clock = FrameClock::new(60);
88    /// clock.advance(Duration::from_millis(16));
89    /// while clock.tick() {
90    ///     // one fixed logic update
91    /// }
92    /// ```
93    #[must_use]
94    pub fn tick(&mut self) -> bool {
95        if self.accumulator >= self.step {
96            self.accumulator -= self.step;
97            true
98        } else {
99            false
100        }
101    }
102
103    /// Fraction of the next step already accumulated, in `0.0..1.0`.
104    ///
105    /// Multiply by the delta between the previous and current state to render an
106    /// interpolated position between fixed logic frames.
107    #[must_use]
108    pub fn alpha(&self) -> f64 {
109        self.accumulator.as_secs_f64() / self.step.as_secs_f64()
110    }
111
112    /// Reset the accumulator. Call after a pause to avoid a burst of catch-up
113    /// steps on the next frame.
114    pub const fn reset(&mut self) {
115        self.accumulator = Duration::ZERO;
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn drains_expected_steps() {
125        let mut clock = FrameClock::new(100); // 10 ms per step
126        clock.advance(Duration::from_millis(35));
127        let mut steps = 0;
128        while clock.tick() {
129            steps += 1;
130        }
131        assert_eq!(steps, 3);
132        // 5 ms of remainder carries over as alpha.
133        assert!((clock.alpha() - 0.5).abs() < 1e-6);
134    }
135
136    #[test]
137    fn caps_catch_up() {
138        let mut clock = FrameClock::new(60);
139        // A huge stall must not produce unbounded steps.
140        clock.advance(Duration::from_secs(10));
141        let mut steps = 0;
142        while clock.tick() {
143            steps += 1;
144        }
145        assert_eq!(steps, 5); // clamped to max_accumulate (5 steps)
146    }
147
148    #[test]
149    fn reset_clears_accumulator() {
150        let mut clock = FrameClock::new(60);
151        clock.advance(Duration::from_millis(100));
152        clock.reset();
153        assert!(!clock.tick());
154    }
155}