use std::error::Error;
use std::fmt;
use std::time::{Duration, Instant};
const DEFAULT_MAX_FRAME_DELTA: Duration = Duration::from_millis(250);
const DEFAULT_MAX_FIXED_STEPS: u32 = 8;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct RuntimeConfig {
fixed_step: Duration,
max_frame_delta: Duration,
max_fixed_steps: u32,
}
impl RuntimeConfig {
pub fn fixed_hz(updates_per_second: u32) -> Result<Self, RuntimeConfigError> {
if updates_per_second == 0 {
return Err(RuntimeConfigError::ZeroUpdateRate);
}
let fixed_step = Duration::from_secs_f64(1.0 / f64::from(updates_per_second));
if fixed_step.is_zero() {
return Err(RuntimeConfigError::UpdateRateTooHigh);
}
Ok(Self {
fixed_step,
max_frame_delta: DEFAULT_MAX_FRAME_DELTA,
max_fixed_steps: DEFAULT_MAX_FIXED_STEPS,
})
}
pub fn with_max_frame_delta(
mut self,
max_frame_delta: Duration,
) -> Result<Self, RuntimeConfigError> {
if max_frame_delta.is_zero() {
return Err(RuntimeConfigError::ZeroMaxFrameDelta);
}
self.max_frame_delta = max_frame_delta;
Ok(self)
}
pub fn with_max_fixed_steps(
mut self,
max_fixed_steps: u32,
) -> Result<Self, RuntimeConfigError> {
if max_fixed_steps == 0 {
return Err(RuntimeConfigError::ZeroMaxFixedSteps);
}
self.max_fixed_steps = max_fixed_steps;
Ok(self)
}
#[must_use]
pub const fn fixed_step(self) -> Duration {
self.fixed_step
}
#[must_use]
pub const fn max_frame_delta(self) -> Duration {
self.max_frame_delta
}
#[must_use]
pub const fn max_fixed_steps(self) -> u32 {
self.max_fixed_steps
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum RuntimeConfigError {
ZeroUpdateRate,
UpdateRateTooHigh,
ZeroMaxFrameDelta,
ZeroMaxFixedSteps,
}
impl fmt::Display for RuntimeConfigError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(match self {
Self::ZeroUpdateRate => "fixed update rate must be greater than zero",
Self::UpdateRateTooHigh => "fixed update rate is too high to represent",
Self::ZeroMaxFrameDelta => "maximum frame delta must be greater than zero",
Self::ZeroMaxFixedSteps => "maximum fixed steps must be greater than zero",
})
}
}
impl Error for RuntimeConfigError {}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FramePlan {
frame_delta: Duration,
fixed_step: Duration,
fixed_steps: u32,
interpolation: f64,
dropped_time: Duration,
}
impl FramePlan {
#[must_use]
pub const fn frame_delta(self) -> Duration {
self.frame_delta
}
#[must_use]
pub const fn fixed_step(self) -> Duration {
self.fixed_step
}
#[must_use]
pub const fn fixed_steps(self) -> u32 {
self.fixed_steps
}
#[must_use]
pub const fn interpolation(self) -> f64 {
self.interpolation
}
#[must_use]
pub const fn dropped_time(self) -> Duration {
self.dropped_time
}
}
#[derive(Debug)]
pub(crate) struct FrameClock {
config: RuntimeConfig,
previous: Instant,
accumulator: Duration,
suspended: bool,
}
impl FrameClock {
pub(crate) const fn new(config: RuntimeConfig, started_at: Instant) -> Self {
Self {
config,
previous: started_at,
accumulator: Duration::ZERO,
suspended: false,
}
}
pub(crate) fn advance(&mut self, now: Instant) -> FramePlan {
if self.suspended {
return self.idle_plan();
}
let elapsed = now.saturating_duration_since(self.previous);
self.previous = now;
self.advance_by(elapsed)
}
pub(crate) const fn suspend(&mut self) {
self.suspended = true;
}
pub(crate) const fn resume(&mut self, now: Instant) {
self.previous = now;
self.suspended = false;
}
pub(crate) const fn suspended(&self) -> bool {
self.suspended
}
fn idle_plan(&self) -> FramePlan {
FramePlan {
frame_delta: Duration::ZERO,
fixed_step: self.config.fixed_step,
fixed_steps: 0,
interpolation: duration_ratio(self.accumulator, self.config.fixed_step),
dropped_time: Duration::ZERO,
}
}
fn advance_by(&mut self, elapsed: Duration) -> FramePlan {
let frame_delta = elapsed.min(self.config.max_frame_delta);
let mut dropped_time = elapsed.saturating_sub(frame_delta);
self.accumulator += frame_delta;
let available_steps = duration_ratio_floor(self.accumulator, self.config.fixed_step);
let fixed_steps =
u32::try_from(available_steps.min(u128::from(self.config.max_fixed_steps)))
.expect("fixed step count is bounded by a u32 configuration value");
self.accumulator -= self.config.fixed_step * fixed_steps;
let excess_steps = available_steps.saturating_sub(u128::from(fixed_steps));
if excess_steps != 0 {
let remainder_nanos = self.accumulator.as_nanos() % self.config.fixed_step.as_nanos();
let remainder = Duration::from_nanos(
u64::try_from(remainder_nanos)
.expect("fixed-step remainder is less than one second"),
);
let excess_duration = self
.accumulator
.checked_sub(remainder)
.expect("fixed-step remainder cannot exceed the accumulator");
self.accumulator = remainder;
dropped_time += excess_duration;
}
FramePlan {
frame_delta,
fixed_step: self.config.fixed_step,
fixed_steps,
interpolation: duration_ratio(self.accumulator, self.config.fixed_step),
dropped_time,
}
}
}
fn duration_ratio_floor(numerator: Duration, denominator: Duration) -> u128 {
numerator.as_nanos() / denominator.as_nanos()
}
#[allow(clippy::cast_precision_loss)]
fn duration_ratio(numerator: Duration, denominator: Duration) -> f64 {
numerator.as_secs_f64() / denominator.as_secs_f64()
}
#[cfg(test)]
mod tests {
use std::time::{Duration, Instant};
use super::{FrameClock, RuntimeConfig, RuntimeConfigError};
#[test]
fn rejects_zero_limits() {
assert_eq!(
RuntimeConfig::fixed_hz(0),
Err(RuntimeConfigError::ZeroUpdateRate)
);
assert_eq!(
RuntimeConfig::fixed_hz(u32::MAX),
Err(RuntimeConfigError::UpdateRateTooHigh)
);
assert_eq!(
RuntimeConfig::fixed_hz(60)
.unwrap()
.with_max_frame_delta(Duration::ZERO),
Err(RuntimeConfigError::ZeroMaxFrameDelta)
);
assert_eq!(
RuntimeConfig::fixed_hz(60).unwrap().with_max_fixed_steps(0),
Err(RuntimeConfigError::ZeroMaxFixedSteps)
);
}
#[test]
fn accumulates_fixed_steps_and_render_interpolation() {
let start = Instant::now();
let config = RuntimeConfig::fixed_hz(10).unwrap();
let mut clock = FrameClock::new(config, start);
let first = clock.advance(start + Duration::from_millis(40));
assert_eq!(first.fixed_steps(), 0);
assert!((first.interpolation() - 0.4).abs() < f64::EPSILON);
let second = clock.advance(start + Duration::from_millis(125));
assert_eq!(second.fixed_steps(), 1);
assert!((second.interpolation() - 0.25).abs() < f64::EPSILON);
}
#[test]
fn clamps_long_frames_and_discards_excess_catch_up_steps() {
let start = Instant::now();
let config = RuntimeConfig::fixed_hz(100)
.unwrap()
.with_max_frame_delta(Duration::from_millis(100))
.unwrap()
.with_max_fixed_steps(3)
.unwrap();
let mut clock = FrameClock::new(config, start);
let plan = clock.advance(start + Duration::from_millis(250));
assert_eq!(plan.frame_delta(), Duration::from_millis(100));
assert_eq!(plan.fixed_steps(), 3);
assert_eq!(plan.dropped_time(), Duration::from_millis(220));
assert!(plan.interpolation().abs() < f64::EPSILON);
}
#[test]
fn suspension_freezes_time_and_preserves_interpolation() {
let start = Instant::now();
let config = RuntimeConfig::fixed_hz(10).unwrap();
let mut clock = FrameClock::new(config, start);
let before = clock.advance(start + Duration::from_millis(40));
assert!((before.interpolation() - 0.4).abs() < f64::EPSILON);
clock.suspend();
let suspended = clock.advance(start + Duration::from_mins(1));
assert_eq!(suspended.frame_delta(), Duration::ZERO);
assert_eq!(suspended.fixed_steps(), 0);
assert!((suspended.interpolation() - 0.4).abs() < f64::EPSILON);
let resumed_at = start + Duration::from_mins(2);
clock.resume(resumed_at);
let resumed = clock.advance(resumed_at + Duration::from_millis(60));
assert_eq!(resumed.fixed_steps(), 1);
assert!(resumed.interpolation().abs() < f64::EPSILON);
assert_eq!(resumed.dropped_time(), Duration::ZERO);
}
}