//! 帧率统计。 //! //! # 必须按"帧数除以墙钟",不是"帧间隔的平均" //! //! 这两种算法在理想链路上给出同一个数,所以错误的那一版能一路跑到现场才暴露。 //! //! 界面每 33ms 轮询一次,而相机出帧间隔是 45ms。也就是说**一次轮询之间可能完成零帧 //! 或多帧**。对帧间隔取平均,量到的是**显示**的刷新节奏而不是**相机**的出帧节奏—— //! 于是上限 22fps 的传感器被报成 36fps。**一个笃定的错误数字比没有数字更糟**:现场 //! 会以为还有性能余量,然后花时间去查一个不存在的问题。 //! //! 这个错误在本项目里出现过两次(旧客户端的 `View::update_fps`、探测脚本的分窗打印), //! 两次都是同一个形状:分子用累计值,分母用窗口值,于是报出随时间单调上升的假数字。 //! 现在它的正确性由一个直接对着数字写的测试盯着。 //! //! # 时钟是可注入的 //! //! [`FpsWindow::observe`] 收 `now` 而不是自己取 `Instant::now()`,这样测试可以用 //! 明确的时刻验证窗口边界——否则只能 sleep,而 sleep 出来的测试既慢又不稳。 use std::time::{Duration, Instant}; /// 帧率统计窗口。默认 2 秒。 pub const DEFAULT_WINDOW: Duration = Duration::from_secs(2); /// 某一路的帧率窗口。 #[derive(Debug, Clone)] pub struct FpsWindow { window: Duration, started: Instant, /// 上一次结算时的累计帧数。 frames: u64, /// 已经结算过几个窗口。 /// /// 不能拿"累计帧数非零"来代替它:窗口的基线是**播种**进去的(见 [`FpsWindow::seeded`]), /// 一块掉线又回来的板子一建立窗口就带着一个很大的累计值。用帧数判断的话,会在一个 /// 窗口都还没走完时就宣称"有读数了",界面于是先显示 0.0,再跳到真值。 completed: u32, fps: f32, } impl Default for FpsWindow { fn default() -> Self { Self::new(DEFAULT_WINDOW) } } impl FpsWindow { pub fn new(window: Duration) -> Self { Self::starting_at(window, Instant::now()) } /// 窗口从指定的时刻开始,并把累计帧数的基线设成给定值。 /// /// 基线不能总是 0:`frames_shown` 是**自进程启动以来**的累计值,而一块掉线又回来的 /// 板子会带着一个已经涨了很大的数字回来。基线设错(比如设成 0)时,下一个窗口会 /// 报出几百 fps 的假数字。 pub fn seeded(window: Duration, started: Instant, frames_shown: u64) -> Self { Self { window, started, frames: frames_shown, completed: 0, fps: 0.0, } } /// 窗口从指定的时刻开始,基线为 0。测试用。 pub fn starting_at(window: Duration, started: Instant) -> Self { Self::seeded(window, started, 0) } /// 最近一次结算出的帧率。窗口还没满时是 0。 pub fn fps(&self) -> f32 { self.fps } /// 窗口是否已经结算过至少一次。 /// /// 界面用它在"还没收到帧"和"帧率真的是 0"之间区分——前者显示 0.0 会被读成"板子 /// 卡死了"。 pub fn has_reading(&self) -> bool { self.completed > 0 } /// 喂进当前的累计帧数,返回本窗口的帧率(窗口未满时返回 `None`)。 /// /// `frames_shown` 是**自进程启动以来**的累计值,不是窗口内的增量——这正是重点: /// 窗口内的增量由这里相减得到,调用方不需要(也就不会弄错)维护它。 pub fn observe(&mut self, frames_shown: u64, now: Instant) -> Option { let elapsed = now.saturating_duration_since(self.started); if elapsed < self.window { return None; } self.fps = (frames_shown.saturating_sub(self.frames)) as f32 / elapsed.as_secs_f32(); self.frames = frames_shown; self.started = now; self.completed += 1; Some(self.fps) } /// 换了一路画面:窗口重开。 /// /// 不重开的话,新画面的第一段统计里带着上一块板子的累计帧数,会报出一个凭空多出来 /// 的数字。旧客户端对此的处理是"`view.sender_id` 变了就整体重建 `View`",那条 /// 规则现在收在这里。 pub fn reset(&mut self, now: Instant) { self.started = now; self.frames = 0; self.completed = 0; self.fps = 0.0; } } #[cfg(test)] mod tests { use super::*; #[test] fn rate_is_frames_over_wallclock() { let t0 = Instant::now(); let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0); // 2 秒里到了 44 帧 = 22 fps。 let fps = window .observe(44, t0 + Duration::from_secs(2)) .expect("the window is full"); assert!((fps - 22.0).abs() < 0.001, "got {fps}"); } #[test] fn a_half_full_window_reports_nothing() { let t0 = Instant::now(); let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0); assert_eq!(window.observe(10, t0 + Duration::from_millis(1999)), None); assert_eq!(window.fps(), 0.0); assert!(!window.has_reading()); } #[test] fn a_stalled_stream_reports_zero_not_a_stale_number() { let t0 = Instant::now(); let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0); window.observe(44, t0 + Duration::from_secs(2)); // 板子不出帧了:累计值不变,下一个窗口必须报 0。 let fps = window .observe(44, t0 + Duration::from_secs(4)) .expect("second window"); assert_eq!(fps, 0.0); } #[test] fn averaging_frame_gaps_is_the_wrong_answer() { // 这条测试把"错误算法会给出什么"钉下来,避免有人"顺手修一下"。 // // 场景:界面每 33ms 轮询一次,相机 45ms 出一帧。一次轮询里轮空、下一次里 // 完成一帧,还有一次轮询之间完成两帧。对帧间隔取平均会算成约 33ms 一帧 // (30fps 上下),而墙钟给出的才是真实的 22fps。 let t0 = Instant::now(); let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0); // 2 秒内累计 44 帧,但最后一次轮询与上一帧之间只隔了 33ms。 let fps = window .observe(44, t0 + Duration::from_secs(2)) .expect("window full"); assert!((fps - 22.0).abs() < 0.001, "wallclock rate: {fps}"); assert!( fps < 25.0, "a frame-gap average would have reported ~30fps here" ); } #[test] fn a_completed_window_is_not_a_reading_yet_until_it_is_full() { // 建立窗口的那一轮不该产出数字:它连一个完整窗口都还没过。 let t0 = Instant::now(); let mut window = FpsWindow::seeded(Duration::from_secs(2), t0, 9_000); assert_eq!(window.observe(9_010, t0 + Duration::from_millis(10)), None); assert!(!window.has_reading()); // 2 秒后:这 2 秒里到了 44 帧。 let fps = window .observe(9_044, t0 + Duration::from_secs(2)) .expect("window full"); assert!((fps - 22.0).abs() < 0.001, "got {fps}"); } #[test] fn resetting_clears_the_cumulative_count() { let t0 = Instant::now(); let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0); window.observe(1000, t0 + Duration::from_secs(2)); // 换了一路画面:累计值从 0 重新开始。 window.reset(t0 + Duration::from_secs(2)); let fps = window .observe(44, t0 + Duration::from_secs(4)) .expect("window full"); assert!((fps - 22.0).abs() < 0.001, "got {fps}"); } #[test] fn a_counter_that_goes_backwards_does_not_underflow() { // 累计值只应该单调增。真出现回退(换了板子但没 reset),宁可报 0 也不要用 // u64 回绕算出一个天文数字。 let t0 = Instant::now(); let mut window = FpsWindow::starting_at(Duration::from_secs(2), t0); window.observe(500, t0 + Duration::from_secs(2)); let fps = window .observe(10, t0 + Duration::from_secs(4)) .expect("window full"); assert_eq!(fps, 0.0); } }