Skip to main content

sgl_core/
time.rs

1//! Fixed-step clock with render interpolation.
2//!
3//! The loop accumulates real frame time and runs the fixed-step simulation in
4//! constant `1/hz` slices. The per-frame delta fed into the accumulator is
5//! **clamped** to one fixed step so the accumulator never routinely crosses
6//! the 2-step threshold when the render rate sits near the fixed rate — the
7//! proven fix for the 60 Hz "rubber-banding" cadence oscillation. The clamp
8//! trades real-time catch-up under heavy stalls for visual stability, which is
9//! the right trade for a render-paced client loop.
10
11/// Default simulation rate: 60 Hz.
12pub const DEFAULT_HZ: f32 = 60.0;
13
14/// Clamp on the per-frame delta, as a multiple of `fixed_dt`. At 1.0 a frame
15/// can consume at most one fixed step — the anti-oscillation choice.
16const MAX_FRAME_DT_MULTIPLIER: f32 = 1.0;
17
18/// A fixed-timestep accumulator + render interpolation factor.
19///
20/// Usage per rendered frame:
21/// ```text
22/// clock.begin_frame(real_dt);
23/// while clock.step() { /* one fixed sim step of clock.fixed_dt */ }
24/// clock.finish();
25/// let alpha = clock.alpha; // overstep fraction: lerp(prev, curr, alpha)
26/// ```
27#[derive(Debug, Clone, Copy)]
28pub struct FixedClock {
29    /// Constant simulation step, `1 / hz` seconds.
30    pub fixed_dt: f32,
31    /// Unconsumed real time carried between frames (seconds).
32    accumulator: f32,
33    /// Clamp on the per-frame delta fed into the accumulator (seconds).
34    max_frame_dt: f32,
35    /// Overstep fraction in `[0, 1)`, set by [`finish`](Self::finish); the
36    /// render interpolation `alpha`.
37    pub alpha: f32,
38    /// Fixed steps consumed during the current frame (cadence diagnostic).
39    pub steps_this_frame: u32,
40}
41
42impl FixedClock {
43    /// A clock at the default 60 Hz simulation rate.
44    #[must_use]
45    pub fn new() -> Self {
46        Self::with_hz(DEFAULT_HZ)
47    }
48
49    /// A clock at an arbitrary fixed rate (`hz` must be positive).
50    #[must_use]
51    pub fn with_hz(hz: f32) -> Self {
52        assert!(hz > 0.0, "FixedClock: hz must be positive, got {hz}");
53        let fixed_dt = 1.0 / hz;
54        Self {
55            fixed_dt,
56            accumulator: 0.0,
57            max_frame_dt: fixed_dt * MAX_FRAME_DT_MULTIPLIER,
58            alpha: 0.0,
59            steps_this_frame: 0,
60        }
61    }
62
63    /// Begin a rendered frame: add `real_dt` (clamped) to the accumulator and
64    /// reset the per-frame step counter.
65    pub fn begin_frame(&mut self, real_dt: f32) {
66        self.accumulator += real_dt.min(self.max_frame_dt);
67        self.steps_this_frame = 0;
68    }
69
70    /// Consume one fixed step if enough time has accumulated. Drive the fixed
71    /// simulation in a `while clock.step() { .. }` loop.
72    pub fn step(&mut self) -> bool {
73        if self.accumulator >= self.fixed_dt {
74            self.accumulator -= self.fixed_dt;
75            self.steps_this_frame += 1;
76            true
77        } else {
78            false
79        }
80    }
81
82    /// Finish the frame: compute the render interpolation `alpha` from the
83    /// leftover accumulator (the overstep fraction).
84    pub fn finish(&mut self) {
85        self.alpha = self.accumulator / self.fixed_dt;
86    }
87}
88
89impl Default for FixedClock {
90    fn default() -> Self {
91        Self::new()
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use wasm_bindgen_test::wasm_bindgen_test;
99
100    /// #249: the per-frame clamp follows the configured rate, not 60 Hz: a
101    /// 30 Hz clock fed half a second runs exactly one step and rests.
102    #[wasm_bindgen_test(unsupported = test)]
103    fn custom_hz_clamps_to_its_own_step() {
104        let mut clock = FixedClock::with_hz(30.0);
105        clock.begin_frame(0.5);
106        assert!(clock.step());
107        assert!(!clock.step());
108        clock.finish();
109        assert_eq!(clock.alpha.to_bits(), 0);
110        assert_eq!(clock.steps_this_frame, 1);
111    }
112
113    /// Drive the clock with a synthetic constant `real_dt` for `frames` frames
114    /// and return the per-frame step counts, asserting the alpha contract.
115    fn run(real_dt: f32, frames: usize) -> Vec<u32> {
116        let mut clock = FixedClock::new();
117        let mut counts = Vec::with_capacity(frames);
118        for _ in 0..frames {
119            clock.begin_frame(real_dt);
120            while clock.step() {}
121            clock.finish();
122            counts.push(clock.steps_this_frame);
123            assert!(
124                (0.0..1.0).contains(&clock.alpha),
125                "alpha must be in [0,1): got {}",
126                clock.alpha
127            );
128        }
129        counts
130    }
131
132    /// The core guarantee: with the clamp, cadence stays at <=1 step per frame
133    /// and never oscillates 1<->2, across common render rates.
134    #[wasm_bindgen_test(unsupported = test)]
135    fn cadence_never_oscillates_with_clamp() {
136        for hz in [60.0_f32, 75.0, 120.0, 144.0] {
137            let counts = run(1.0 / hz, 600);
138            // Skip warm-up: the first frames may run 0 steps while the
139            // accumulator fills. Steady-state cadence is what matters.
140            let steady = &counts[2..];
141            assert!(
142                steady.iter().all(|&c| c <= 1),
143                "{hz} Hz: a frame ran >1 step (sample: {:?})",
144                &steady[..steady.len().min(20)]
145            );
146        }
147    }
148
149    /// At exactly the fixed rate (60 Hz) the steady cadence is a solid 1 step
150    /// per frame (the historically jittery case).
151    #[wasm_bindgen_test(unsupported = test)]
152    fn at_fixed_rate_cadence_is_one() {
153        let counts = run(1.0 / 60.0, 300);
154        let steady = &counts[2..];
155        assert!(
156            steady.iter().all(|&c| c == 1),
157            "60 Hz steady cadence should be all 1s, got sample: {:?}",
158            &steady[..steady.len().min(20)]
159        );
160    }
161
162    /// Faster-than-fixed render rates never exceed 1 step/frame.
163    #[wasm_bindgen_test(unsupported = test)]
164    fn faster_render_never_exceeds_one_step() {
165        for hz in [120.0_f32, 144.0, 240.0] {
166            let counts = run(1.0 / hz, 300);
167            assert!(
168                counts.iter().all(|&c| c <= 1),
169                "{hz} Hz exceeded 1 step/frame"
170            );
171        }
172    }
173
174    /// At half the fixed rate (30 Hz render) roughly one step per frame still
175    /// runs (the clamp caps each frame at one step of virtual time).
176    #[wasm_bindgen_test(unsupported = test)]
177    fn slow_render_is_clamped_to_one_step() {
178        let counts = run(1.0 / 30.0, 300);
179        let steady = &counts[2..];
180        assert!(
181            steady.iter().all(|&c| c == 1),
182            "30 Hz should clamp to exactly 1 step/frame, got sample: {:?}",
183            &steady[..steady.len().min(20)]
184        );
185    }
186
187    /// A custom-rate clock steps at its own cadence.
188    #[wasm_bindgen_test(unsupported = test)]
189    fn custom_hz_sets_fixed_dt() {
190        let clock = FixedClock::with_hz(30.0);
191        assert!((clock.fixed_dt - 1.0 / 30.0).abs() < f32::EPSILON);
192    }
193}