use crate::compat::{vec, Vec};
pub const DEFAULT_HISTORY_CAPACITY: usize = 60;
#[derive(Debug, Clone)]
pub struct FrameTimeMonitor {
frame_times: Vec<f32>,
index: usize,
count: usize,
target_frame_time: f32,
}
impl FrameTimeMonitor {
pub fn new(target_frame_rate: f32) -> Self {
Self::with_capacity(target_frame_rate, DEFAULT_HISTORY_CAPACITY)
}
pub fn with_capacity(target_frame_rate: f32, capacity: usize) -> Self {
let capacity = capacity.max(1);
Self {
frame_times: vec![0.0; capacity],
index: 0,
count: 0,
target_frame_time: 1.0 / target_frame_rate,
}
}
pub fn with_capacity_for_counts(
target_frame_rate: f32,
degrade_frame_count: usize,
upgrade_frame_count: usize,
) -> Self {
let required = degrade_frame_count.max(upgrade_frame_count).max(1);
Self::with_capacity(target_frame_rate, required)
}
pub fn capacity(&self) -> usize {
self.frame_times.len()
}
pub fn record_frame(&mut self, frame_duration: f32) {
if !frame_duration.is_finite() || frame_duration < 0.0 {
return;
}
self.frame_times[self.index] = frame_duration;
self.index = (self.index + 1) % self.frame_times.len();
self.count = self.count.saturating_add(1).min(self.frame_times.len());
}
pub fn average_frame_time(&self) -> f32 {
if self.count == 0 {
return 0.0;
}
let mut sum = 0.0f32;
let mut samples = 0usize;
for &time in self.frame_times.iter().take(self.count) {
if time.is_finite() {
sum += time;
samples += 1;
}
}
if samples == 0 {
return 0.0;
}
sum / samples as f32
}
pub fn current_fps(&self) -> f32 {
let avg = self.average_frame_time();
if avg.is_finite() && avg > 0.0 {
1.0 / avg
} else {
0.0
}
}
fn recent_window_start(&self, consecutive_frames: usize) -> Option<usize> {
if consecutive_frames == 0 || self.count < consecutive_frames {
return None;
}
let len = self.frame_times.len();
let start = (self.index + len - (consecutive_frames % len)) % len;
Some(start)
}
pub fn should_degrade(&self, threshold_duration: f32, consecutive_frames: usize) -> bool {
let Some(start) = self.recent_window_start(consecutive_frames) else {
return false;
};
let len = self.frame_times.len();
for i in 0..consecutive_frames {
let idx = (start + i) % len;
let time = self.frame_times[idx];
if !time.is_finite() || time <= threshold_duration {
return false;
}
}
true
}
pub fn should_upgrade(&self, threshold_duration: f32, consecutive_frames: usize) -> bool {
let Some(start) = self.recent_window_start(consecutive_frames) else {
return false;
};
let len = self.frame_times.len();
for i in 0..consecutive_frames {
let idx = (start + i) % len;
let time = self.frame_times[idx];
if !time.is_finite() || time > threshold_duration {
return false;
}
}
true
}
pub fn reset(&mut self) {
self.index = 0;
self.count = 0;
self.frame_times.fill(0.0);
}
pub fn set_target_frame_rate(&mut self, frame_rate: f32) {
self.target_frame_time = 1.0 / frame_rate;
}
pub fn target_frame_time(&self) -> f32 {
self.target_frame_time
}
}
impl Default for FrameTimeMonitor {
fn default() -> Self {
Self::new(60.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn an_invalid_sample_is_rejected_and_cannot_satisfy_contradictory_verdicts() {
for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1.0] {
let mut monitor = FrameTimeMonitor::new(60.0);
monitor.record_frame(bad);
assert_eq!(monitor.average_frame_time(), 0.0, "{bad} must not enter the average");
assert_eq!(monitor.count, 0, "{bad} must not be counted as a sample");
assert!(!monitor.should_degrade(0.02, 1), "{bad} satisfied should_degrade");
assert!(!monitor.should_upgrade(0.01, 1), "{bad} satisfied should_upgrade");
}
}
#[test]
fn a_rejected_sample_does_not_evict_a_recorded_one() {
let mut monitor = FrameTimeMonitor::new(60.0);
monitor.record_frame(0.016);
monitor.record_frame(f32::NAN);
monitor.record_frame(-5.0);
assert_eq!(monitor.count, 1, "only the finite, non-negative sample was stored");
assert!((monitor.average_frame_time() - 0.016).abs() < 1e-9);
}
#[test]
fn valid_samples_drive_degrade_and_upgrade_normally() {
let mut slow = FrameTimeMonitor::new(60.0);
for _ in 0..3 {
slow.record_frame(0.05);
}
assert!(slow.should_degrade(0.02, 3), "three slow frames must degrade");
assert!(!slow.should_upgrade(0.02, 3), "slow frames must not upgrade");
let mut fast = FrameTimeMonitor::new(60.0);
for _ in 0..3 {
fast.record_frame(0.005);
}
assert!(fast.should_upgrade(0.01, 3), "three fast frames must upgrade");
assert!(!fast.should_degrade(0.01, 3), "fast frames must not degrade");
}
#[test]
fn a_window_larger_than_the_buffer_is_refused_rather_than_reading_stale_slots() {
let mut monitor = FrameTimeMonitor::with_capacity(60.0, 60);
for _ in 0..60 {
monitor.record_frame(0.05);
}
assert_eq!(monitor.count, 60);
assert!(monitor.should_degrade(0.02, 60), "a full 60-sample window must degrade");
assert!(
!monitor.should_degrade(0.02, 61),
"61 samples cannot be shown when the buffer holds 60"
);
assert!(!monitor.should_upgrade(0.02, 61));
}
#[test]
fn a_longer_window_is_reachable_when_the_buffer_is_sized_for_it() {
let mut monitor = FrameTimeMonitor::with_capacity_for_counts(60.0, 90, 30);
assert_eq!(monitor.capacity(), 90);
for _ in 0..89 {
monitor.record_frame(0.05);
}
assert!(!monitor.should_degrade(0.02, 90), "89 of 90 samples is not yet a full window");
monitor.record_frame(0.05);
assert!(monitor.should_degrade(0.02, 90), "the 90th slow frame completes the window");
for _ in 0..200 {
monitor.record_frame(0.001);
}
assert!(!monitor.should_degrade(0.02, 90), "recent frames are fast, so no degrade");
assert!(monitor.should_upgrade(0.02, 90), "recent frames are all fast");
}
#[test]
fn the_window_wraps_to_the_most_recent_samples() {
let mut monitor = FrameTimeMonitor::with_capacity(60.0, 4);
for _ in 0..4 {
monitor.record_frame(0.05);
}
assert!(monitor.should_degrade(0.02, 4));
for _ in 0..8 {
monitor.record_frame(0.001);
}
assert!(!monitor.should_degrade(0.02, 4), "the slow frames were evicted by the wrap");
assert!(monitor.should_upgrade(0.02, 4), "the newest four frames are fast");
}
#[test]
fn a_zero_capacity_buffer_is_raised_to_one_and_a_zero_window_is_refused() {
let mut monitor = FrameTimeMonitor::with_capacity(60.0, 0);
assert_eq!(monitor.capacity(), 1);
monitor.record_frame(0.05);
assert_eq!(monitor.count, 1);
assert!(monitor.should_degrade(0.02, 1));
assert!(!monitor.should_degrade(0.02, 0), "zero frames is not a window");
assert!(!monitor.should_upgrade(0.02, 0));
}
}