1use std::time::Duration;
22
23pub const DEFAULT_HZ: f32 = 60.0;
25
26const ALPHA_MAX: f32 = 1.0 - f32::EPSILON / 2.0;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct CatchUp {
39 pub max_steps_per_frame: u32,
41 pub max_debt_steps: u32,
43}
44
45#[derive(Debug, Clone, Copy)]
55pub struct FixedClock {
56 fixed_step: Duration,
59 accumulator: Duration,
61 catch_up: Option<CatchUp>,
63 pub alpha: f32,
66 pub steps_this_frame: u32,
68 pub dropped_dt: Duration,
72}
73
74impl FixedClock {
75 #[must_use]
77 pub fn new() -> Self {
78 Self::with_hz(DEFAULT_HZ)
79 }
80
81 #[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 #[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 #[must_use]
118 pub fn fixed_step(&self) -> Duration {
119 self.fixed_step
120 }
121
122 #[must_use]
124 pub fn fixed_dt(&self) -> f32 {
125 self.fixed_step.as_secs_f32()
126 }
127
128 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 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 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 pub fn finish(&mut self) {
172 let step = self.fixed_step.as_nanos();
173 #[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 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 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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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}