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}