use std::collections::VecDeque;
use std::time::{Duration, Instant};
use tracing::debug;
const FRAME_WINDOW_SIZE: usize = 60;
const MIN_FPS: u32 = 1;
const MAX_FPS: u32 = 240;
#[derive(Debug)]
pub struct FrameTimer {
target_fps: u32,
frame_duration: Duration,
last_frame: Instant,
frame_times: VecDeque<Duration>,
}
impl FrameTimer {
#[must_use]
pub fn new(target_fps: u32) -> Self {
let fps = target_fps.clamp(MIN_FPS, MAX_FPS);
Self {
target_fps: fps,
frame_duration: Duration::from_secs_f64(1.0 / f64::from(fps)),
last_frame: Instant::now(),
frame_times: VecDeque::with_capacity(FRAME_WINDOW_SIZE),
}
}
pub fn wait_for_next_frame(&mut self) {
let now = Instant::now();
let elapsed = now.duration_since(self.last_frame);
if self.frame_times.len() >= FRAME_WINDOW_SIZE {
self.frame_times.pop_front();
}
self.frame_times.push_back(elapsed);
let sleep_duration = self.frame_duration.saturating_sub(elapsed);
if sleep_duration > Duration::ZERO {
std::thread::sleep(sleep_duration);
} else if elapsed > self.frame_duration {
debug!(
"Frame drop: frame took {:?}, target {:?}",
elapsed, self.frame_duration
);
}
self.last_frame = Instant::now();
}
#[must_use]
#[allow(
clippy::cast_precision_loss,
reason = "Frame count fits in f32 mantissa, precision loss is negligible"
)]
pub fn actual_fps(&self) -> f32 {
if self.frame_times.is_empty() {
return 0.0;
}
let total: Duration = self.frame_times.iter().sum();
#[allow(
clippy::cast_possible_truncation,
reason = "Frame window size is 60, well within u32 range"
)]
let count = self.frame_times.len() as u32;
let avg = total / count;
if avg.as_secs_f32() > 0.0 {
1.0 / avg.as_secs_f32()
} else {
0.0
}
}
#[must_use]
pub fn frame_time(&self) -> Duration {
self.frame_times.back().copied().unwrap_or(Duration::ZERO)
}
#[must_use]
pub const fn target_fps(&self) -> u32 {
self.target_fps
}
#[must_use]
pub const fn target_frame_time(&self) -> Duration {
self.frame_duration
}
pub fn reset(&mut self) {
self.last_frame = Instant::now();
self.frame_times.clear();
}
}
impl Default for FrameTimer {
fn default() -> Self {
Self::new(60)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_new_60fps_frame_duration() {
let timer = FrameTimer::new(60);
assert_eq!(timer.target_fps(), 60);
let duration_ms = timer.target_frame_time().as_secs_f64() * 1000.0;
assert!(
(duration_ms - 16.666_666).abs() < 0.001,
"Expected ~16.67ms, got {duration_ms}ms"
);
}
#[test]
fn test_new_min_fps() {
let timer = FrameTimer::new(1);
assert_eq!(timer.target_fps(), 1);
let duration_ms = timer.target_frame_time().as_secs_f64() * 1000.0;
assert!(
(duration_ms - 1000.0).abs() < 0.1,
"Expected 1000ms, got {duration_ms}ms"
);
}
#[test]
fn test_new_max_fps() {
let timer = FrameTimer::new(240);
assert_eq!(timer.target_fps(), 240);
let duration_ms = timer.target_frame_time().as_secs_f64() * 1000.0;
assert!(
(duration_ms - 4.166_666).abs() < 0.001,
"Expected ~4.17ms, got {duration_ms}ms"
);
}
#[test]
fn test_new_clamps_below_min() {
let timer = FrameTimer::new(0);
assert_eq!(timer.target_fps(), 1, "FPS 0 should be clamped to 1");
}
#[test]
fn test_new_clamps_above_max() {
let timer = FrameTimer::new(500);
assert_eq!(timer.target_fps(), 240, "FPS 500 should be clamped to 240");
}
#[test]
fn test_target_fps_returns_correct_value() {
let timer = FrameTimer::new(30);
assert_eq!(timer.target_fps(), 30);
let timer = FrameTimer::new(120);
assert_eq!(timer.target_fps(), 120);
}
#[test]
fn test_actual_fps_no_frames() {
let timer = FrameTimer::new(60);
assert!(
(timer.actual_fps() - 0.0).abs() < f32::EPSILON,
"Expected 0.0, got {}",
timer.actual_fps()
);
}
#[test]
fn test_frame_time_no_frames() {
let timer = FrameTimer::new(60);
assert_eq!(timer.frame_time(), Duration::ZERO);
}
#[test]
fn test_reset_clears_history() {
let mut timer = FrameTimer::new(60);
timer.wait_for_next_frame();
assert!(
timer.frame_time() > Duration::ZERO,
"Should have recorded a frame"
);
timer.reset();
assert_eq!(timer.actual_fps(), 0.0);
assert_eq!(timer.frame_time(), Duration::ZERO);
}
#[test]
fn test_default_is_60fps() {
let timer = FrameTimer::default();
assert_eq!(timer.target_fps(), 60);
}
#[test]
fn test_timing_accuracy_60fps() {
let mut timer = FrameTimer::new(60);
let target_frame_ms = 16.67;
let start = Instant::now();
for _ in 0..30 {
timer.wait_for_next_frame();
}
let elapsed = start.elapsed();
let expected_ms = 30.0 * target_frame_ms;
let actual_ms = elapsed.as_secs_f64() * 1000.0;
let diff_ms = (actual_ms - expected_ms).abs();
assert!(
diff_ms < expected_ms * 0.3, "Expected ~{expected_ms}ms total, got {actual_ms}ms (diff: {diff_ms}ms)"
);
let fps = timer.actual_fps();
assert!(
fps > 30.0,
"Expected FPS > 30, got {fps} - timing mechanism may not be working"
);
}
#[test]
fn test_frame_drop_no_sleep() {
let mut timer = FrameTimer::new(60);
timer.wait_for_next_frame();
std::thread::sleep(Duration::from_millis(30));
let before = Instant::now();
timer.wait_for_next_frame();
let elapsed = before.elapsed();
assert!(
elapsed < Duration::from_millis(5),
"Should not sleep on frame drop, but waited {elapsed:?}"
);
}
}