Skip to main content

sgl_core/
time.rs

1//! Fixed-step clock with render interpolation.
2//!
3//! The loop accumulates caller-supplied frame time as an exact [`Duration`]
4//! and runs the fixed-step simulation in constant steps of `1/hz` seconds
5//! rounded to the nearest nanosecond. A clock picks one of two policies:
6//!
7//! - **Render-paced** ([`FixedClock::with_hz`], the default): the per-frame
8//!   delta fed into the accumulator is **clamped** to one fixed step, so the
9//!   accumulator never routinely crosses the 2-step threshold when the render
10//!   rate sits near the fixed rate — the proven fix for the 60 Hz
11//!   "rubber-banding" cadence oscillation. The clamp trades real-time
12//!   catch-up for visual stability: simulation runs slower than real time
13//!   whenever frames are longer than a step.
14//! - **Bounded catch-up** ([`FixedClock::with_catch_up`]): the whole delta
15//!   is accumulated and a frame runs up to [`CatchUp::max_steps_per_frame`]
16//!   steps, so simulation follows elapsed time while frames are slower than
17//!   the tick rate. Time beyond that budget is discarded, apart from up to
18//!   [`CatchUp::max_debt_steps`] whole steps carried to later frames. For an
19//!   authoritative or networked simulation that must keep pace with time.
20
21use std::time::Duration;
22
23/// Default simulation rate: 60 Hz.
24pub const DEFAULT_HZ: f32 = 60.0;
25
26/// The largest `f32` below 1: `alpha` stays in `[0, 1)` under rounding.
27const ALPHA_MAX: f32 = 1.0 - f32::EPSILON / 2.0;
28
29/// Bounded catch-up policy for [`FixedClock::with_catch_up`].
30///
31/// Each frame runs at most `max_steps_per_frame` steps. Whole steps still due
32/// after that budget are carried to later frames up to `max_debt_steps`; the
33/// rest is discarded and reported in [`FixedClock::dropped_dt`]. With
34/// `max_debt_steps: 0` a stall runs one full budget and the next normal
35/// frame runs normally; a larger debt replays more of the stall, at up to a
36/// full budget per frame, until it is repaid.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct CatchUp {
39    /// Most fixed steps one frame may run (at least 1).
40    pub max_steps_per_frame: u32,
41    /// Whole steps carried past a frame's budget; the excess is discarded.
42    pub max_debt_steps: u32,
43}
44
45/// A fixed-timestep accumulator + render interpolation factor.
46///
47/// Usage per rendered frame:
48/// ```text
49/// clock.begin_frame(frame_elapsed); // a Duration
50/// while clock.step() { /* one fixed sim step of clock.fixed_dt() */ }
51/// clock.finish();
52/// let alpha = clock.alpha; // overstep fraction: lerp(prev, curr, alpha)
53/// ```
54#[derive(Debug, Clone, Copy)]
55pub struct FixedClock {
56    /// Constant simulation step: `1 / hz` seconds rounded to the nearest
57    /// nanosecond.
58    fixed_step: Duration,
59    /// Unconsumed supplied time carried between frames.
60    accumulator: Duration,
61    /// `None` is the render-paced default.
62    catch_up: Option<CatchUp>,
63    /// Overstep fraction in `[0, 1)`, set by [`finish`](Self::finish); the
64    /// render interpolation `alpha`. Carried debt is not part of it.
65    pub alpha: f32,
66    /// Fixed steps consumed during the current frame (cadence diagnostic).
67    pub steps_this_frame: u32,
68    /// Supplied time this frame will never simulate, set by
69    /// [`begin_frame`](Self::begin_frame): the clamped-off delta when
70    /// render-paced, whole discarded steps under a catch-up policy.
71    pub dropped_dt: Duration,
72}
73
74impl FixedClock {
75    /// A render-paced clock at the default 60 Hz simulation rate.
76    #[must_use]
77    pub fn new() -> Self {
78        Self::with_hz(DEFAULT_HZ)
79    }
80
81    /// A render-paced clock at an arbitrary fixed rate: at most one step per
82    /// frame. The step is `Duration::from_secs_f64(1.0 / f64::from(hz))`;
83    /// `hz` must be positive and give a step of at least one nanosecond.
84    #[must_use]
85    pub fn with_hz(hz: f32) -> Self {
86        let fixed_step = Duration::try_from_secs_f64(1.0 / f64::from(hz))
87            .ok()
88            .filter(|step| !step.is_zero())
89            .unwrap_or_else(|| {
90                panic!("FixedClock: hz must be positive with a step of at least 1 ns, got {hz}")
91            });
92        Self {
93            fixed_step,
94            accumulator: Duration::ZERO,
95            catch_up: None,
96            alpha: 0.0,
97            steps_this_frame: 0,
98            dropped_dt: Duration::ZERO,
99        }
100    }
101
102    /// A clock that follows elapsed time with bounded catch-up (`hz` as for
103    /// [`with_hz`](Self::with_hz) and `max_steps_per_frame` at least 1).
104    #[must_use]
105    pub fn with_catch_up(hz: f32, catch_up: CatchUp) -> Self {
106        assert!(
107            catch_up.max_steps_per_frame > 0,
108            "FixedClock: max_steps_per_frame must be at least 1"
109        );
110        Self {
111            catch_up: Some(catch_up),
112            ..Self::with_hz(hz)
113        }
114    }
115
116    /// The exact simulation step.
117    #[must_use]
118    pub fn fixed_step(&self) -> Duration {
119        self.fixed_step
120    }
121
122    /// The simulation step in seconds, for the game's integration.
123    #[must_use]
124    pub fn fixed_dt(&self) -> f32 {
125        self.fixed_step.as_secs_f32()
126    }
127
128    /// Begin a rendered frame: add the frame's elapsed time to the
129    /// accumulator under the clock's policy, record
130    /// [`dropped_dt`](Self::dropped_dt), and reset the per-frame step
131    /// counter.
132    pub fn begin_frame(&mut self, elapsed: Duration) {
133        self.steps_this_frame = 0;
134        let Some(policy) = self.catch_up else {
135            let added = elapsed.min(self.fixed_step);
136            self.dropped_dt = elapsed.saturating_sub(added);
137            self.accumulator = self.accumulator.saturating_add(added);
138            return;
139        };
140        self.dropped_dt = Duration::ZERO;
141        self.accumulator = self.accumulator.saturating_add(elapsed);
142        let step = self.fixed_step.as_nanos();
143        let held = self.accumulator.as_nanos();
144        let kept_steps = u128::from(policy.max_steps_per_frame) + u128::from(policy.max_debt_steps);
145        if held / step > kept_steps {
146            // Keep `kept_steps` whole steps and the fractional remainder, so
147            // `alpha` stays continuous across the drop.
148            let retained = kept_steps * step + held % step;
149            self.dropped_dt = Duration::from_nanos_u128(held - retained);
150            self.accumulator = self.accumulator.saturating_sub(self.dropped_dt);
151        }
152    }
153
154    /// Consume one fixed step if enough time has accumulated and the frame's
155    /// step budget allows. Drive the fixed simulation in a
156    /// `while clock.step() { .. }` loop.
157    pub fn step(&mut self) -> bool {
158        let budget = self.catch_up.map_or(u32::MAX, |p| p.max_steps_per_frame);
159        if self.accumulator >= self.fixed_step && self.steps_this_frame < budget {
160            self.accumulator = self.accumulator.saturating_sub(self.fixed_step);
161            self.steps_this_frame += 1;
162            true
163        } else {
164            false
165        }
166    }
167
168    /// Finish the frame: compute the render interpolation `alpha` from the
169    /// leftover accumulator (the overstep fraction, excluding whole steps of
170    /// carried debt).
171    pub fn finish(&mut self) {
172        let step = self.fixed_step.as_nanos();
173        // The rounded f64 ratio only feeds presentation.
174        #[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
175        let fraction = ((self.accumulator.as_nanos() % step) as f64 / step as f64) as f32;
176        self.alpha = fraction.min(ALPHA_MAX);
177    }
178}
179
180impl Default for FixedClock {
181    fn default() -> Self {
182        Self::new()
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use wasm_bindgen_test::wasm_bindgen_test;
190
191    /// The 30 Hz step worked by hand: 1 s / 30 to the nearest nanosecond.
192    const STEP_30HZ_NS: u128 = 33_333_333;
193
194    fn catch_up(max_steps_per_frame: u32, max_debt_steps: u32) -> CatchUp {
195        CatchUp {
196            max_steps_per_frame,
197            max_debt_steps,
198        }
199    }
200
201    /// Run one frame and return the steps it took.
202    fn frame(clock: &mut FixedClock, elapsed: Duration) -> u32 {
203        clock.begin_frame(elapsed);
204        while clock.step() {}
205        clock.finish();
206        assert!((0.0..1.0).contains(&clock.alpha), "alpha {}", clock.alpha);
207        clock.steps_this_frame
208    }
209
210    /// The fraction of a 30 Hz step left over after `elapsed_ns`.
211    #[allow(clippy::cast_precision_loss)]
212    fn phase_30hz(elapsed_ns: u128) -> f64 {
213        (elapsed_ns % STEP_30HZ_NS) as f64 / STEP_30HZ_NS as f64
214    }
215
216    /// #249: the per-frame clamp follows the configured rate, not 60 Hz: a
217    /// 30 Hz clock fed half a second runs exactly one step and rests,
218    /// reporting the rest of the half second as dropped.
219    #[wasm_bindgen_test(unsupported = test)]
220    fn custom_hz_clamps_to_its_own_step() {
221        let mut clock = FixedClock::with_hz(30.0);
222        assert_eq!(frame(&mut clock, Duration::from_millis(500)), 1);
223        assert_eq!(clock.alpha.to_bits(), 0);
224        assert_eq!(clock.dropped_dt.as_nanos(), 500_000_000 - STEP_30HZ_NS);
225    }
226
227    /// Drive a 60 Hz render-paced clock with a constant frame time
228    /// `1 / render_hz` for `frames` frames and return the per-frame steps.
229    fn run(render_hz: f64, frames: usize) -> Vec<u32> {
230        let mut clock = FixedClock::new();
231        let elapsed = Duration::from_secs_f64(1.0 / render_hz);
232        (0..frames).map(|_| frame(&mut clock, elapsed)).collect()
233    }
234
235    /// The core guarantee: with the clamp, cadence stays at <=1 step per frame
236    /// and never oscillates 1<->2, across common render rates.
237    #[wasm_bindgen_test(unsupported = test)]
238    fn cadence_never_oscillates_with_clamp() {
239        for hz in [60.0, 75.0, 120.0, 144.0] {
240            let counts = run(hz, 600);
241            // Skip warm-up: the first frames may run 0 steps while the
242            // accumulator fills. Steady-state cadence is what matters.
243            let steady = &counts[2..];
244            assert!(
245                steady.iter().all(|&c| c <= 1),
246                "{hz} Hz: a frame ran >1 step (sample: {:?})",
247                &steady[..steady.len().min(20)]
248            );
249        }
250    }
251
252    /// At exactly the fixed rate (60 Hz) the steady cadence is a solid 1 step
253    /// per frame (the historically jittery case).
254    #[wasm_bindgen_test(unsupported = test)]
255    fn at_fixed_rate_cadence_is_one() {
256        let counts = run(60.0, 300);
257        let steady = &counts[2..];
258        assert!(
259            steady.iter().all(|&c| c == 1),
260            "60 Hz steady cadence should be all 1s, got sample: {:?}",
261            &steady[..steady.len().min(20)]
262        );
263    }
264
265    /// Faster-than-fixed render rates never exceed 1 step/frame.
266    #[wasm_bindgen_test(unsupported = test)]
267    fn faster_render_never_exceeds_one_step() {
268        for hz in [120.0, 144.0, 240.0] {
269            let counts = run(hz, 300);
270            assert!(
271                counts.iter().all(|&c| c <= 1),
272                "{hz} Hz exceeded 1 step/frame"
273            );
274        }
275    }
276
277    /// At half the fixed rate (30 Hz render) roughly one step per frame still
278    /// runs (the clamp caps each frame at one step of virtual time).
279    #[wasm_bindgen_test(unsupported = test)]
280    fn slow_render_is_clamped_to_one_step() {
281        let counts = run(30.0, 300);
282        let steady = &counts[2..];
283        assert!(
284            steady.iter().all(|&c| c == 1),
285            "30 Hz should clamp to exactly 1 step/frame, got sample: {:?}",
286            &steady[..steady.len().min(20)]
287        );
288    }
289
290    /// A game that times frames with `Duration::from_secs_f64(1.0 / 30.0)`
291    /// at 30 Hz gets one tick per frame from the first frame under either
292    /// policy: ticks so far are the elapsed nanoseconds divided by the step.
293    #[wasm_bindgen_test(unsupported = test)]
294    fn one_step_frames_tick_from_the_first_frame() {
295        let elapsed = Duration::from_secs_f64(1.0 / 30.0);
296        for mut clock in [
297            FixedClock::with_hz(30.0),
298            FixedClock::with_catch_up(30.0, catch_up(8, 0)),
299        ] {
300            let mut ticks = 0;
301            for n in 1..=300 {
302                ticks += u128::from(frame(&mut clock, elapsed));
303                assert_eq!(ticks, n * elapsed.as_nanos() / STEP_30HZ_NS, "frame {n}");
304                assert!(clock.dropped_dt.is_zero());
305            }
306        }
307    }
308
309    /// #270: with enough budget, simulation follows elapsed time below the
310    /// tick rate. One second as ten 100 ms frames at 30 Hz is exactly
311    /// floor(1 s / step) ticks; the render-paced default runs one per frame.
312    #[wasm_bindgen_test(unsupported = test)]
313    fn catch_up_follows_elapsed_time() {
314        let (elapsed, frames) = (Duration::from_millis(100), 10);
315        let mut clock = FixedClock::with_catch_up(30.0, catch_up(8, 0));
316        let ticks: u32 = (0..frames).map(|_| frame(&mut clock, elapsed)).sum();
317        assert_eq!(u128::from(ticks), 1_000_000_000 / STEP_30HZ_NS);
318        assert!(clock.dropped_dt.is_zero());
319
320        let mut paced = FixedClock::with_hz(30.0);
321        let ticks: u32 = (0..frames).map(|_| frame(&mut paced, elapsed)).sum();
322        assert_eq!(ticks, frames);
323    }
324
325    /// After multi-step frames, `alpha` is the fraction of a step left over
326    /// from elapsed time: 110 ms frames at 30 Hz are 3.3 steps each, so no
327    /// tested frame ends on a whole step.
328    #[wasm_bindgen_test(unsupported = test)]
329    fn catch_up_interpolates_the_elapsed_remainder() {
330        let elapsed = Duration::from_millis(110);
331        let mut clock = FixedClock::with_catch_up(30.0, catch_up(8, 0));
332        let mut ticks = 0;
333        for n in 1..=8 {
334            ticks += u128::from(frame(&mut clock, elapsed));
335            let elapsed_ns = n * elapsed.as_nanos();
336            assert_eq!(ticks, elapsed_ns / STEP_30HZ_NS, "frame {n}");
337            let want = phase_30hz(elapsed_ns);
338            assert!(
339                (f64::from(clock.alpha) - want).abs() < 1e-6,
340                "frame {n}: alpha {} want {want}",
341                clock.alpha
342            );
343        }
344    }
345
346    /// A 5 s stall at 30 Hz is 150 whole steps and 50 ns. With a budget of 8
347    /// and no debt the frame runs 8, drops exactly the other 142 whole
348    /// steps, and keeps the 50 ns phase; the next 1/30 s frame runs one.
349    #[wasm_bindgen_test(unsupported = test)]
350    fn five_second_stall_drops_whole_steps_and_keeps_the_phase() {
351        let stall_ns = 5_000_000_000;
352        let mut clock = FixedClock::with_catch_up(30.0, catch_up(8, 0));
353        assert_eq!(frame(&mut clock, Duration::from_secs(5)), 8);
354        assert_eq!(clock.dropped_dt.as_nanos(), 142 * STEP_30HZ_NS);
355        let phase = phase_30hz(stall_ns);
356        assert!((f64::from(clock.alpha) - phase).abs() < 1e-9);
357
358        assert_eq!(frame(&mut clock, Duration::from_secs_f64(1.0 / 30.0)), 1);
359        assert!(clock.dropped_dt.is_zero());
360        assert!((f64::from(clock.alpha) - phase).abs() < 1e-9);
361    }
362
363    /// A stall of 150 and a half steps at 30 Hz. The budget runs
364    /// `max_steps_per_frame`, carries at most `max_debt_steps`, and drops
365    /// exactly the rest; the following one-step frames repay the debt at up
366    /// to a full budget each, then run one step, keeping the half step.
367    #[wasm_bindgen_test(unsupported = test)]
368    fn stall_is_capped_and_excess_follows_the_policy() {
369        let half = STEP_30HZ_NS / 2;
370        let stall = Duration::from_nanos_u128(150 * STEP_30HZ_NS + half);
371        let one_step = Duration::from_nanos_u128(STEP_30HZ_NS);
372        let phase = phase_30hz(half);
373        for (budget, debt) in [(8_u32, 0_u32), (8, 4), (8, 20)] {
374            let mut clock = FixedClock::with_catch_up(30.0, catch_up(budget, debt));
375            assert_eq!(frame(&mut clock, stall), budget);
376            let dropped_steps = 150 - u128::from(budget + debt);
377            assert_eq!(clock.dropped_dt.as_nanos(), dropped_steps * STEP_30HZ_NS);
378            assert!((f64::from(clock.alpha) - phase).abs() < 1e-6);
379
380            // Each normal frame adds one step to the carried debt.
381            let mut owed = debt;
382            for n in 0..6 {
383                let want = (owed + 1).min(budget);
384                assert_eq!(
385                    frame(&mut clock, one_step),
386                    want,
387                    "{budget}/{debt} frame {n}"
388                );
389                assert!(clock.dropped_dt.is_zero());
390                assert!((f64::from(clock.alpha) - phase).abs() < 1e-6);
391                owed = owed + 1 - want;
392            }
393        }
394    }
395
396    /// The longest representable stall, twice in a row, saturates instead of
397    /// overflowing: a render-paced frame drops all but one step, a catch-up
398    /// frame runs its budget, and later one-step frames tick once each.
399    #[wasm_bindgen_test(unsupported = test)]
400    fn maximal_stall_saturates_and_recovers() {
401        let one_step = Duration::from_nanos_u128(STEP_30HZ_NS);
402        let mut paced = FixedClock::with_hz(30.0);
403        for _ in 0..2 {
404            assert_eq!(frame(&mut paced, Duration::MAX), 1);
405            assert_eq!(paced.dropped_dt, Duration::MAX.saturating_sub(one_step));
406        }
407        let mut caught_up = FixedClock::with_catch_up(30.0, catch_up(8, 4));
408        for _ in 0..2 {
409            assert_eq!(frame(&mut caught_up, Duration::MAX), 8);
410        }
411        for mut clock in [paced, caught_up] {
412            // Drain any carried debt, then count steps over 30 frames.
413            for _ in 0..4 {
414                frame(&mut clock, one_step);
415            }
416            let ticks: u32 = (0..30).map(|_| frame(&mut clock, one_step)).sum();
417            assert_eq!(ticks, 30);
418            assert!(clock.dropped_dt.is_zero());
419        }
420    }
421}