#![allow(dead_code)]
#[derive(Debug, Clone)]
pub struct FpsCounterConfig {
pub sample_window: usize,
pub min_delta_secs: f32,
pub stability_threshold: f32,
}
impl Default for FpsCounterConfig {
fn default() -> Self {
Self {
sample_window: 60,
min_delta_secs: 1e-6,
stability_threshold: 5.0,
}
}
}
#[derive(Debug, Clone)]
pub struct FpsCounter {
pub config: FpsCounterConfig,
samples: Vec<f32>,
total_frames: u64,
min_observed: f32,
max_observed: f32,
}
pub type FpsJson = String;
#[allow(dead_code)]
pub fn default_fps_config() -> FpsCounterConfig {
FpsCounterConfig::default()
}
#[allow(dead_code)]
pub fn new_fps_counter(config: FpsCounterConfig) -> FpsCounter {
FpsCounter {
config,
samples: Vec::new(),
total_frames: 0,
min_observed: f32::MAX,
max_observed: 0.0,
}
}
#[allow(dead_code)]
pub fn tick_fps(mut counter: FpsCounter, delta_secs: f32) -> FpsCounter {
let dt = delta_secs.max(counter.config.min_delta_secs);
let fps = 1.0 / dt;
if fps < counter.min_observed {
counter.min_observed = fps;
}
if fps > counter.max_observed {
counter.max_observed = fps;
}
counter.total_frames += 1;
counter.samples.push(fps);
let win = counter.config.sample_window;
if counter.samples.len() > win {
let excess = counter.samples.len() - win;
counter.samples.drain(..excess);
}
counter
}
#[allow(dead_code)]
pub fn current_fps(counter: &FpsCounter) -> f32 {
counter.samples.last().copied().unwrap_or(0.0)
}
#[allow(dead_code)]
pub fn average_fps(counter: &FpsCounter) -> f32 {
let n = counter.samples.len();
if n == 0 {
return 0.0;
}
counter.samples.iter().sum::<f32>() / n as f32
}
#[allow(dead_code)]
pub fn min_fps(counter: &FpsCounter) -> f32 {
if counter.total_frames == 0 {
0.0
} else {
counter.min_observed
}
}
#[allow(dead_code)]
pub fn max_fps(counter: &FpsCounter) -> f32 {
counter.max_observed
}
#[allow(dead_code)]
pub fn fps_to_string(counter: &FpsCounter) -> String {
format!("{:.1} FPS", current_fps(counter))
}
#[allow(dead_code)]
pub fn reset_fps_counter(mut counter: FpsCounter) -> FpsCounter {
counter.samples.clear();
counter.total_frames = 0;
counter.min_observed = f32::MAX;
counter.max_observed = 0.0;
counter
}
#[allow(dead_code)]
pub fn frame_count(counter: &FpsCounter) -> u64 {
counter.total_frames
}
#[allow(dead_code)]
pub fn fps_counter_to_json(counter: &FpsCounter) -> FpsJson {
let mn = if counter.total_frames == 0 {
0.0
} else {
counter.min_observed
};
format!(
"{{\"current_fps\":{:.2},\"average_fps\":{:.2},\"min_fps\":{:.2},\
\"max_fps\":{:.2},\"frame_count\":{},\"sample_window\":{}}}",
current_fps(counter),
average_fps(counter),
mn,
counter.max_observed,
counter.total_frames,
counter.config.sample_window,
)
}
#[allow(dead_code)]
pub fn set_sample_window(mut counter: FpsCounter, window: usize) -> FpsCounter {
counter.config.sample_window = window;
if counter.samples.len() > window {
let excess = counter.samples.len() - window;
counter.samples.drain(..excess);
}
counter
}
#[allow(dead_code)]
pub fn is_fps_stable(counter: &FpsCounter) -> bool {
let n = counter.samples.len();
if n < 2 {
return false;
}
let mean = average_fps(counter);
let variance: f32 = counter
.samples
.iter()
.map(|&s| (s - mean) * (s - mean))
.sum::<f32>()
/ n as f32;
variance.sqrt() < counter.config.stability_threshold
}
#[cfg(test)]
mod tests {
use super::*;
fn tick_n(counter: FpsCounter, delta: f32, n: u32) -> FpsCounter {
(0..n).fold(counter, |c, _| tick_fps(c, delta))
}
#[test]
fn default_config_fields() {
let cfg = default_fps_config();
assert_eq!(cfg.sample_window, 60);
assert!((cfg.min_delta_secs - 1e-6).abs() < 1e-9);
assert!((cfg.stability_threshold - 5.0).abs() < 1e-4);
}
#[test]
fn new_counter_zero_state() {
let c = new_fps_counter(default_fps_config());
assert_eq!(frame_count(&c), 0);
assert!((current_fps(&c)).abs() < 1e-6);
assert!((average_fps(&c)).abs() < 1e-6);
assert!((min_fps(&c)).abs() < 1e-6);
assert!((max_fps(&c)).abs() < 1e-6);
}
#[test]
fn tick_one_frame_60fps() {
let c = new_fps_counter(default_fps_config());
let c = tick_fps(c, 1.0 / 60.0);
assert!((current_fps(&c) - 60.0).abs() < 0.01);
assert_eq!(frame_count(&c), 1);
}
#[test]
fn average_fps_over_uniform_ticks() {
let c = new_fps_counter(default_fps_config());
let c = tick_n(c, 1.0 / 60.0, 10);
assert!((average_fps(&c) - 60.0).abs() < 0.1);
}
#[test]
fn min_fps_tracks_minimum() {
let c = new_fps_counter(default_fps_config());
let c = tick_fps(c, 1.0 / 30.0); let c = tick_fps(c, 1.0 / 60.0); assert!((min_fps(&c) - 30.0).abs() < 0.1);
}
#[test]
fn max_fps_tracks_maximum() {
let c = new_fps_counter(default_fps_config());
let c = tick_fps(c, 1.0 / 30.0); let c = tick_fps(c, 1.0 / 120.0); assert!((max_fps(&c) - 120.0).abs() < 0.5);
}
#[test]
fn fps_to_string_format() {
let c = new_fps_counter(default_fps_config());
let c = tick_fps(c, 1.0 / 60.0);
let s = fps_to_string(&c);
assert!(s.ends_with(" FPS"));
}
#[test]
fn reset_clears_all() {
let c = new_fps_counter(default_fps_config());
let c = tick_n(c, 1.0 / 60.0, 5);
let c = reset_fps_counter(c);
assert_eq!(frame_count(&c), 0);
assert!((current_fps(&c)).abs() < 1e-6);
}
#[test]
fn frame_count_increments() {
let c = new_fps_counter(default_fps_config());
let c = tick_n(c, 0.016, 7);
assert_eq!(frame_count(&c), 7);
}
#[test]
fn fps_counter_to_json_valid() {
let c = new_fps_counter(default_fps_config());
let c = tick_fps(c, 0.016);
let j = fps_counter_to_json(&c);
assert!(j.starts_with('{'));
assert!(j.ends_with('}'));
assert!(j.contains("current_fps"));
}
#[test]
fn set_sample_window_shrinks_samples() {
let c = new_fps_counter(default_fps_config());
let c = tick_n(c, 0.016, 10);
assert_eq!(c.samples.len(), 10);
let c = set_sample_window(c, 5);
assert_eq!(c.samples.len(), 5);
assert_eq!(c.config.sample_window, 5);
}
#[test]
fn is_fps_stable_true_for_uniform() {
let c = new_fps_counter(default_fps_config());
let c = tick_n(c, 1.0 / 60.0, 30);
assert!(is_fps_stable(&c));
}
#[test]
fn is_fps_stable_false_for_varying() {
let c = new_fps_counter(default_fps_config());
let c = tick_fps(c, 1.0 / 5.0); let c = tick_fps(c, 1.0 / 120.0); let c = tick_fps(c, 1.0 / 5.0);
let c = tick_fps(c, 1.0 / 120.0);
assert!(!is_fps_stable(&c));
}
#[test]
fn is_fps_stable_false_with_one_sample() {
let c = new_fps_counter(default_fps_config());
let c = tick_fps(c, 0.016);
assert!(!is_fps_stable(&c));
}
#[test]
fn window_limits_sample_count() {
let mut cfg = default_fps_config();
cfg.sample_window = 3;
let c = new_fps_counter(cfg);
let c = tick_n(c, 0.016, 10);
assert_eq!(c.samples.len(), 3);
}
#[test]
fn min_delta_guards_division_by_zero() {
let c = new_fps_counter(default_fps_config());
let c = tick_fps(c, 0.0);
assert!(current_fps(&c) > 0.0);
}
#[test]
fn total_frames_survives_window_trim() {
let mut cfg = default_fps_config();
cfg.sample_window = 5;
let c = new_fps_counter(cfg);
let c = tick_n(c, 0.016, 20);
assert_eq!(frame_count(&c), 20);
assert_eq!(c.samples.len(), 5);
}
}