use core::time::Duration;
#[derive(Debug, Clone)]
pub struct FrameStats<const N: usize = 120> {
samples: [Duration; N],
len: usize,
head: usize,
frames: u64,
}
impl<const N: usize> Default for FrameStats<N> {
fn default() -> Self {
Self::new()
}
}
impl<const N: usize> FrameStats<N> {
#[must_use]
pub const fn new() -> Self {
Self {
samples: [Duration::ZERO; N],
len: 0,
head: 0,
frames: 0,
}
}
pub fn record(&mut self, delta: Duration) {
self.frames = self.frames.wrapping_add(1);
if N == 0 {
return;
}
self.samples[self.head] = delta;
self.head = (self.head + 1) % N;
self.len = (self.len + 1).min(N);
}
#[must_use]
pub const fn frame_count(&self) -> u64 {
self.frames
}
const fn start(&self) -> usize {
if self.len < N { 0 } else { self.head }
}
#[must_use]
pub fn samples(&self) -> impl ExactSizeIterator<Item = Duration> + '_ {
let start = self.start();
let len = self.len;
let modulus = if N == 0 { 1 } else { N };
(0..len).map(move |i| self.samples[(start + i) % modulus])
}
#[must_use]
pub const fn current(&self) -> Duration {
if self.len == 0 || N == 0 {
return Duration::ZERO;
}
self.samples[(self.head + N - 1) % N]
}
#[must_use]
pub fn avg(&self) -> Duration {
if self.len == 0 {
return Duration::ZERO;
}
#[allow(clippy::cast_possible_truncation)]
let len = self.len as u32;
self.samples().sum::<Duration>() / len
}
#[must_use]
pub fn min(&self) -> Duration {
self.samples().min().unwrap_or_default()
}
#[must_use]
pub fn max(&self) -> Duration {
self.samples().max().unwrap_or_default()
}
#[must_use]
pub fn fps(&self) -> f32 {
let avg = self.avg().as_secs_f32();
if avg <= 0.0 { 0.0 } else { 1.0 / avg }
}
}
#[cfg(test)]
mod tests {
use super::*;
fn settled<const N: usize>(millis: u64, frames: usize) -> FrameStats<N> {
let mut stats = FrameStats::new();
for _ in 0..frames {
stats.record(Duration::from_millis(millis));
}
stats
}
#[test]
fn empty_stats_read_as_zero() {
let stats = FrameStats::<8>::new();
assert_eq!(stats.frame_count(), 0);
assert_eq!(stats.current(), Duration::ZERO);
assert_eq!(stats.avg(), Duration::ZERO);
assert_eq!(stats.min(), Duration::ZERO);
assert_eq!(stats.max(), Duration::ZERO);
assert!((stats.fps() - 0.0).abs() < f32::EPSILON);
assert_eq!(stats.samples().count(), 0);
}
#[test]
fn steady_frames_report_consistent_stats() {
let stats = settled::<8>(16, 5);
assert_eq!(stats.frame_count(), 5);
assert_eq!(stats.current(), Duration::from_millis(16));
assert_eq!(stats.avg(), Duration::from_millis(16));
assert_eq!(stats.min(), Duration::from_millis(16));
assert_eq!(stats.max(), Duration::from_millis(16));
assert!((stats.fps() - 62.5).abs() < 0.1);
assert_eq!(stats.samples().count(), 5);
}
#[test]
fn ring_buffer_wraps_and_drops_the_oldest_sample() {
let mut stats = FrameStats::<3>::new();
for ms in [10, 20, 30, 40] {
stats.record(Duration::from_millis(ms));
}
assert_eq!(stats.frame_count(), 4);
let samples: Vec<Duration> = stats.samples().collect();
assert_eq!(
samples,
[10, 20, 30, 40][1..]
.iter()
.map(|&ms| Duration::from_millis(ms))
.collect::<Vec<_>>()
);
assert_eq!(stats.current(), Duration::from_millis(40));
assert_eq!(stats.min(), Duration::from_millis(20));
assert_eq!(stats.max(), Duration::from_millis(40));
}
#[test]
fn min_and_max_track_the_extremes_of_a_varying_window() {
let mut stats = FrameStats::<8>::new();
for ms in [16, 16, 40, 16, 8, 16] {
stats.record(Duration::from_millis(ms));
}
assert_eq!(stats.min(), Duration::from_millis(8));
assert_eq!(stats.max(), Duration::from_millis(40));
assert_eq!(stats.current(), Duration::from_millis(16));
}
#[test]
fn zero_capacity_window_still_counts_frames_without_panicking() {
let mut stats = FrameStats::<0>::new();
stats.record(Duration::from_millis(16));
stats.record(Duration::from_millis(16));
assert_eq!(stats.frame_count(), 2);
assert_eq!(stats.avg(), Duration::ZERO);
assert_eq!(stats.samples().count(), 0);
}
#[test]
fn default_matches_new() {
let stats: FrameStats<4> = FrameStats::default();
assert_eq!(stats.frame_count(), 0);
}
#[test]
fn sub_millisecond_precision_is_not_rounded_away() {
let mut stats = FrameStats::<4>::new();
stats.record(Duration::from_micros(1500)); assert_eq!(stats.current(), Duration::from_micros(1500));
assert_eq!(stats.avg(), Duration::from_micros(1500));
assert_eq!(stats.min(), Duration::from_micros(1500));
assert_eq!(stats.max(), Duration::from_micros(1500));
}
#[test]
fn fps_reflects_a_varying_not_just_steady_window() {
let mut stats = FrameStats::<4>::new();
for ms in [10, 30, 10, 30] {
stats.record(Duration::from_millis(ms));
}
assert!((stats.fps() - 50.0).abs() < 0.1, "fps={}", stats.fps());
}
}