use std::time::Duration;
pub const DEFAULT_HZ: f32 = 60.0;
const ALPHA_MAX: f32 = 1.0 - f32::EPSILON / 2.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CatchUp {
pub max_steps_per_frame: u32,
pub max_debt_steps: u32,
}
#[derive(Debug, Clone, Copy)]
pub struct FixedClock {
fixed_step: Duration,
accumulator: Duration,
catch_up: Option<CatchUp>,
pub alpha: f32,
pub steps_this_frame: u32,
pub dropped_dt: Duration,
}
impl FixedClock {
#[must_use]
pub fn new() -> Self {
Self::with_hz(DEFAULT_HZ)
}
#[must_use]
pub fn with_hz(hz: f32) -> Self {
let fixed_step = Duration::try_from_secs_f64(1.0 / f64::from(hz))
.ok()
.filter(|step| !step.is_zero())
.unwrap_or_else(|| {
panic!("FixedClock: hz must be positive with a step of at least 1 ns, got {hz}")
});
Self {
fixed_step,
accumulator: Duration::ZERO,
catch_up: None,
alpha: 0.0,
steps_this_frame: 0,
dropped_dt: Duration::ZERO,
}
}
#[must_use]
pub fn with_catch_up(hz: f32, catch_up: CatchUp) -> Self {
assert!(
catch_up.max_steps_per_frame > 0,
"FixedClock: max_steps_per_frame must be at least 1"
);
Self {
catch_up: Some(catch_up),
..Self::with_hz(hz)
}
}
#[must_use]
pub fn fixed_step(&self) -> Duration {
self.fixed_step
}
#[must_use]
pub fn fixed_dt(&self) -> f32 {
self.fixed_step.as_secs_f32()
}
pub fn begin_frame(&mut self, elapsed: Duration) {
self.steps_this_frame = 0;
let Some(policy) = self.catch_up else {
let added = elapsed.min(self.fixed_step);
self.dropped_dt = elapsed.saturating_sub(added);
self.accumulator = self.accumulator.saturating_add(added);
return;
};
self.dropped_dt = Duration::ZERO;
self.accumulator = self.accumulator.saturating_add(elapsed);
let step = self.fixed_step.as_nanos();
let held = self.accumulator.as_nanos();
let kept_steps = u128::from(policy.max_steps_per_frame) + u128::from(policy.max_debt_steps);
if held / step > kept_steps {
let retained = kept_steps * step + held % step;
self.dropped_dt = Duration::from_nanos_u128(held - retained);
self.accumulator = self.accumulator.saturating_sub(self.dropped_dt);
}
}
pub fn step(&mut self) -> bool {
let budget = self.catch_up.map_or(u32::MAX, |p| p.max_steps_per_frame);
if self.accumulator >= self.fixed_step && self.steps_this_frame < budget {
self.accumulator = self.accumulator.saturating_sub(self.fixed_step);
self.steps_this_frame += 1;
true
} else {
false
}
}
pub fn finish(&mut self) {
let step = self.fixed_step.as_nanos();
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
let fraction = ((self.accumulator.as_nanos() % step) as f64 / step as f64) as f32;
self.alpha = fraction.min(ALPHA_MAX);
}
}
impl Default for FixedClock {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use wasm_bindgen_test::wasm_bindgen_test;
const STEP_30HZ_NS: u128 = 33_333_333;
fn catch_up(max_steps_per_frame: u32, max_debt_steps: u32) -> CatchUp {
CatchUp {
max_steps_per_frame,
max_debt_steps,
}
}
fn frame(clock: &mut FixedClock, elapsed: Duration) -> u32 {
clock.begin_frame(elapsed);
while clock.step() {}
clock.finish();
assert!((0.0..1.0).contains(&clock.alpha), "alpha {}", clock.alpha);
clock.steps_this_frame
}
#[allow(clippy::cast_precision_loss)]
fn phase_30hz(elapsed_ns: u128) -> f64 {
(elapsed_ns % STEP_30HZ_NS) as f64 / STEP_30HZ_NS as f64
}
#[wasm_bindgen_test(unsupported = test)]
fn custom_hz_clamps_to_its_own_step() {
let mut clock = FixedClock::with_hz(30.0);
assert_eq!(frame(&mut clock, Duration::from_millis(500)), 1);
assert_eq!(clock.alpha.to_bits(), 0);
assert_eq!(clock.dropped_dt.as_nanos(), 500_000_000 - STEP_30HZ_NS);
}
fn run(render_hz: f64, frames: usize) -> Vec<u32> {
let mut clock = FixedClock::new();
let elapsed = Duration::from_secs_f64(1.0 / render_hz);
(0..frames).map(|_| frame(&mut clock, elapsed)).collect()
}
#[wasm_bindgen_test(unsupported = test)]
fn cadence_never_oscillates_with_clamp() {
for hz in [60.0, 75.0, 120.0, 144.0] {
let counts = run(hz, 600);
let steady = &counts[2..];
assert!(
steady.iter().all(|&c| c <= 1),
"{hz} Hz: a frame ran >1 step (sample: {:?})",
&steady[..steady.len().min(20)]
);
}
}
#[wasm_bindgen_test(unsupported = test)]
fn at_fixed_rate_cadence_is_one() {
let counts = run(60.0, 300);
let steady = &counts[2..];
assert!(
steady.iter().all(|&c| c == 1),
"60 Hz steady cadence should be all 1s, got sample: {:?}",
&steady[..steady.len().min(20)]
);
}
#[wasm_bindgen_test(unsupported = test)]
fn faster_render_never_exceeds_one_step() {
for hz in [120.0, 144.0, 240.0] {
let counts = run(hz, 300);
assert!(
counts.iter().all(|&c| c <= 1),
"{hz} Hz exceeded 1 step/frame"
);
}
}
#[wasm_bindgen_test(unsupported = test)]
fn slow_render_is_clamped_to_one_step() {
let counts = run(30.0, 300);
let steady = &counts[2..];
assert!(
steady.iter().all(|&c| c == 1),
"30 Hz should clamp to exactly 1 step/frame, got sample: {:?}",
&steady[..steady.len().min(20)]
);
}
#[wasm_bindgen_test(unsupported = test)]
fn one_step_frames_tick_from_the_first_frame() {
let elapsed = Duration::from_secs_f64(1.0 / 30.0);
for mut clock in [
FixedClock::with_hz(30.0),
FixedClock::with_catch_up(30.0, catch_up(8, 0)),
] {
let mut ticks = 0;
for n in 1..=300 {
ticks += u128::from(frame(&mut clock, elapsed));
assert_eq!(ticks, n * elapsed.as_nanos() / STEP_30HZ_NS, "frame {n}");
assert!(clock.dropped_dt.is_zero());
}
}
}
#[wasm_bindgen_test(unsupported = test)]
fn catch_up_follows_elapsed_time() {
let (elapsed, frames) = (Duration::from_millis(100), 10);
let mut clock = FixedClock::with_catch_up(30.0, catch_up(8, 0));
let ticks: u32 = (0..frames).map(|_| frame(&mut clock, elapsed)).sum();
assert_eq!(u128::from(ticks), 1_000_000_000 / STEP_30HZ_NS);
assert!(clock.dropped_dt.is_zero());
let mut paced = FixedClock::with_hz(30.0);
let ticks: u32 = (0..frames).map(|_| frame(&mut paced, elapsed)).sum();
assert_eq!(ticks, frames);
}
#[wasm_bindgen_test(unsupported = test)]
fn catch_up_interpolates_the_elapsed_remainder() {
let elapsed = Duration::from_millis(110);
let mut clock = FixedClock::with_catch_up(30.0, catch_up(8, 0));
let mut ticks = 0;
for n in 1..=8 {
ticks += u128::from(frame(&mut clock, elapsed));
let elapsed_ns = n * elapsed.as_nanos();
assert_eq!(ticks, elapsed_ns / STEP_30HZ_NS, "frame {n}");
let want = phase_30hz(elapsed_ns);
assert!(
(f64::from(clock.alpha) - want).abs() < 1e-6,
"frame {n}: alpha {} want {want}",
clock.alpha
);
}
}
#[wasm_bindgen_test(unsupported = test)]
fn five_second_stall_drops_whole_steps_and_keeps_the_phase() {
let stall_ns = 5_000_000_000;
let mut clock = FixedClock::with_catch_up(30.0, catch_up(8, 0));
assert_eq!(frame(&mut clock, Duration::from_secs(5)), 8);
assert_eq!(clock.dropped_dt.as_nanos(), 142 * STEP_30HZ_NS);
let phase = phase_30hz(stall_ns);
assert!((f64::from(clock.alpha) - phase).abs() < 1e-9);
assert_eq!(frame(&mut clock, Duration::from_secs_f64(1.0 / 30.0)), 1);
assert!(clock.dropped_dt.is_zero());
assert!((f64::from(clock.alpha) - phase).abs() < 1e-9);
}
#[wasm_bindgen_test(unsupported = test)]
fn stall_is_capped_and_excess_follows_the_policy() {
let half = STEP_30HZ_NS / 2;
let stall = Duration::from_nanos_u128(150 * STEP_30HZ_NS + half);
let one_step = Duration::from_nanos_u128(STEP_30HZ_NS);
let phase = phase_30hz(half);
for (budget, debt) in [(8_u32, 0_u32), (8, 4), (8, 20)] {
let mut clock = FixedClock::with_catch_up(30.0, catch_up(budget, debt));
assert_eq!(frame(&mut clock, stall), budget);
let dropped_steps = 150 - u128::from(budget + debt);
assert_eq!(clock.dropped_dt.as_nanos(), dropped_steps * STEP_30HZ_NS);
assert!((f64::from(clock.alpha) - phase).abs() < 1e-6);
let mut owed = debt;
for n in 0..6 {
let want = (owed + 1).min(budget);
assert_eq!(
frame(&mut clock, one_step),
want,
"{budget}/{debt} frame {n}"
);
assert!(clock.dropped_dt.is_zero());
assert!((f64::from(clock.alpha) - phase).abs() < 1e-6);
owed = owed + 1 - want;
}
}
}
#[wasm_bindgen_test(unsupported = test)]
fn maximal_stall_saturates_and_recovers() {
let one_step = Duration::from_nanos_u128(STEP_30HZ_NS);
let mut paced = FixedClock::with_hz(30.0);
for _ in 0..2 {
assert_eq!(frame(&mut paced, Duration::MAX), 1);
assert_eq!(paced.dropped_dt, Duration::MAX.saturating_sub(one_step));
}
let mut caught_up = FixedClock::with_catch_up(30.0, catch_up(8, 4));
for _ in 0..2 {
assert_eq!(frame(&mut caught_up, Duration::MAX), 8);
}
for mut clock in [paced, caught_up] {
for _ in 0..4 {
frame(&mut clock, one_step);
}
let ticks: u32 = (0..30).map(|_| frame(&mut clock, one_step)).sum();
assert_eq!(ticks, 30);
assert!(clock.dropped_dt.is_zero());
}
}
}