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