1use std::collections::VecDeque;
4use web_time::Instant;
5
6const FRAME_HISTORY_SIZE: usize = 60;
7
8#[derive(Debug)]
9pub(crate) struct FpsMonitor {
10 tracker: FpsTracker,
11 recomposition_count: u64,
12}
13
14impl FpsMonitor {
15 pub(crate) fn new() -> Self {
16 Self {
17 tracker: FpsTracker::new(),
18 recomposition_count: 0,
19 }
20 }
21
22 #[cfg(test)]
23 pub(crate) fn record_frame(&mut self) {
24 self.tracker.record_frame(self.recomposition_count);
25 }
26
27 pub(crate) fn record_frame_work(
28 &mut self,
29 frame_started_at: Instant,
30 frame_finished_at: Instant,
31 ) {
32 self.tracker.record_frame_work(
33 frame_started_at,
34 frame_finished_at,
35 self.recomposition_count,
36 );
37 }
38
39 pub(crate) fn record_recomposition(&mut self) {
40 self.recomposition_count = self.recomposition_count.saturating_add(1);
41 }
42
43 pub(crate) fn reset_stats(&mut self) {
44 self.tracker.reset(self.recomposition_count);
45 }
46
47 pub(crate) fn current_fps(&self) -> f32 {
48 self.tracker.last_fps
49 }
50
51 pub(crate) fn stats(&self) -> FpsStats {
52 self.tracker.stats(self.recomposition_count)
53 }
54}
55
56impl Default for FpsMonitor {
57 fn default() -> Self {
58 Self::new()
59 }
60}
61
62#[derive(Debug)]
63struct FpsTracker {
64 frame_times: VecDeque<Instant>,
65 frame_intervals_ms: VecDeque<f32>,
66 frame_work_ms: VecDeque<f32>,
67 last_fps: f32,
68 frame_count: u64,
69 intervals: FrameIntervalStats,
70 work: FrameIntervalStats,
71 last_recomp_count: u64,
72 recomps_per_second: u64,
73 last_recomp_calc: Instant,
74}
75
76impl FpsTracker {
77 fn new() -> Self {
78 Self {
79 frame_times: VecDeque::with_capacity(FRAME_HISTORY_SIZE + 1),
80 frame_intervals_ms: VecDeque::with_capacity(FRAME_HISTORY_SIZE),
81 frame_work_ms: VecDeque::with_capacity(FRAME_HISTORY_SIZE),
82 last_fps: 0.0,
83 frame_count: 0,
84 intervals: FrameIntervalStats::default(),
85 work: FrameIntervalStats::default(),
86 last_recomp_count: 0,
87 recomps_per_second: 0,
88 last_recomp_calc: Instant::now(),
89 }
90 }
91
92 #[cfg(test)]
93 fn record_frame(&mut self, recomposition_count: u64) {
94 let now = Instant::now();
95 self.record_frame_work(now, now, recomposition_count);
96 }
97
98 #[cfg(test)]
99 fn record_frame_at(&mut self, now: Instant, recomposition_count: u64) {
100 self.record_frame_work(now, now, recomposition_count);
101 }
102
103 fn reset(&mut self, recomposition_count: u64) {
104 self.frame_times.clear();
105 self.frame_intervals_ms.clear();
106 self.frame_work_ms.clear();
107 self.last_fps = 0.0;
108 self.frame_count = 0;
109 self.intervals = FrameIntervalStats::default();
110 self.work = FrameIntervalStats::default();
111 self.last_recomp_count = recomposition_count;
112 self.recomps_per_second = 0;
113 self.last_recomp_calc = Instant::now();
114 }
115
116 fn record_frame_work(
117 &mut self,
118 frame_started_at: Instant,
119 frame_finished_at: Instant,
120 recomposition_count: u64,
121 ) {
122 if let Some(previous) = self.frame_times.back() {
123 let interval_ms = frame_started_at.duration_since(*previous).as_secs_f32() * 1000.0;
124 self.frame_intervals_ms.push_back(interval_ms);
125 while self.frame_intervals_ms.len() > FRAME_HISTORY_SIZE {
126 self.frame_intervals_ms.pop_front();
127 }
128 self.intervals = FrameIntervalStats::from_samples(&self.frame_intervals_ms);
129 self.last_fps = if self.intervals.avg_ms > 0.0 {
130 1000.0 / self.intervals.avg_ms
131 } else {
132 0.0
133 };
134 }
135
136 let work_ms = frame_finished_at
137 .duration_since(frame_started_at)
138 .as_secs_f32()
139 * 1000.0;
140 self.frame_work_ms.push_back(work_ms);
141 while self.frame_work_ms.len() > FRAME_HISTORY_SIZE {
142 self.frame_work_ms.pop_front();
143 }
144 self.work = FrameIntervalStats::from_samples(&self.frame_work_ms);
145
146 self.frame_times.push_back(frame_started_at);
147 self.frame_count += 1;
148
149 while self.frame_times.len() > FRAME_HISTORY_SIZE + 1 {
150 self.frame_times.pop_front();
151 }
152
153 let elapsed = frame_finished_at
154 .duration_since(self.last_recomp_calc)
155 .as_secs_f32();
156 if elapsed >= 1.0 {
157 self.recomps_per_second = recomposition_count.saturating_sub(self.last_recomp_count);
158 self.last_recomp_count = recomposition_count;
159 self.last_recomp_calc = frame_finished_at;
160 }
161 }
162
163 fn stats(&self, recomposition_count: u64) -> FpsStats {
164 FpsStats {
165 fps: self.last_fps,
166 avg_ms: self.intervals.avg_ms,
167 latest_ms: self.intervals.latest_ms,
168 min_ms: self.intervals.min_ms,
169 max_ms: self.intervals.max_ms,
170 p95_ms: self.intervals.p95_ms,
171 p99_ms: self.intervals.p99_ms,
172 work_avg_ms: self.work.avg_ms,
173 work_p95_ms: self.work.p95_ms,
174 work_max_ms: self.work.max_ms,
175 interval_count: self.intervals.count,
176 missed_120hz_budget: self.intervals.missed_120hz_budget,
177 missed_60hz_budget: self.intervals.missed_60hz_budget,
178 stalled_50ms_frames: self.intervals.stalled_50ms_frames,
179 frame_count: self.frame_count,
180 recompositions: recomposition_count,
181 recomps_per_second: self.recomps_per_second,
182 }
183 }
184}
185
186#[derive(Clone, Copy, Debug, Default)]
187struct FrameIntervalStats {
188 count: u32,
189 latest_ms: f32,
190 avg_ms: f32,
191 min_ms: f32,
192 max_ms: f32,
193 p95_ms: f32,
194 p99_ms: f32,
195 missed_120hz_budget: u32,
196 missed_60hz_budget: u32,
197 stalled_50ms_frames: u32,
198}
199
200impl FrameIntervalStats {
201 const FRAME_120HZ_MS: f32 = 1000.0 / 120.0;
202 const FRAME_60HZ_MS: f32 = 1000.0 / 60.0;
203 const STALL_MS: f32 = 50.0;
204
205 fn from_samples(samples: &VecDeque<f32>) -> Self {
206 let count = samples.len();
207 if count == 0 {
208 return Self::default();
209 }
210
211 let mut sorted = [0.0f32; FRAME_HISTORY_SIZE];
212 let mut sum = 0.0f32;
213 let mut min_ms = f32::INFINITY;
214 let mut max_ms = 0.0f32;
215 let mut missed_120hz_budget = 0u32;
216 let mut missed_60hz_budget = 0u32;
217 let mut stalled_50ms_frames = 0u32;
218
219 for (index, interval_ms) in samples.iter().copied().enumerate() {
220 sorted[index] = interval_ms;
221 sum += interval_ms;
222 min_ms = min_ms.min(interval_ms);
223 max_ms = max_ms.max(interval_ms);
224 if interval_ms > Self::FRAME_120HZ_MS {
225 missed_120hz_budget = missed_120hz_budget.saturating_add(1);
226 }
227 if interval_ms > Self::FRAME_60HZ_MS {
228 missed_60hz_budget = missed_60hz_budget.saturating_add(1);
229 }
230 if interval_ms > Self::STALL_MS {
231 stalled_50ms_frames = stalled_50ms_frames.saturating_add(1);
232 }
233 }
234
235 let sorted = &mut sorted[..count];
236 sorted.sort_by(|a, b| a.total_cmp(b));
237
238 Self {
239 count: count as u32,
240 latest_ms: samples.back().copied().unwrap_or_default(),
241 avg_ms: sum / count as f32,
242 min_ms,
243 max_ms,
244 p95_ms: nearest_rank_percentile(sorted, 95),
245 p99_ms: nearest_rank_percentile(sorted, 99),
246 missed_120hz_budget,
247 missed_60hz_budget,
248 stalled_50ms_frames,
249 }
250 }
251}
252
253fn nearest_rank_percentile(sorted_samples: &[f32], percentile: usize) -> f32 {
254 if sorted_samples.is_empty() {
255 return 0.0;
256 }
257 let rank = sorted_samples
258 .len()
259 .saturating_mul(percentile)
260 .div_ceil(100)
261 .saturating_sub(1);
262 sorted_samples[rank.min(sorted_samples.len() - 1)]
263}
264
265#[derive(Clone, Copy, Debug, Default)]
267pub struct FpsStats {
268 pub fps: f32,
270 pub avg_ms: f32,
272 pub latest_ms: f32,
274 pub min_ms: f32,
276 pub max_ms: f32,
278 pub p95_ms: f32,
280 pub p99_ms: f32,
282 pub work_avg_ms: f32,
284 pub work_p95_ms: f32,
286 pub work_max_ms: f32,
288 pub interval_count: u32,
290 pub missed_120hz_budget: u32,
292 pub missed_60hz_budget: u32,
294 pub stalled_50ms_frames: u32,
296 pub frame_count: u64,
298 pub recompositions: u64,
300 pub recomps_per_second: u64,
302}
303
304#[cfg(test)]
305mod tests {
306 use super::{nearest_rank_percentile, FpsMonitor, FpsTracker};
307 use std::time::Duration;
308
309 #[test]
310 fn monitors_do_not_share_recomposition_or_frame_counts() {
311 let mut first = FpsMonitor::new();
312 let mut second = FpsMonitor::new();
313
314 first.record_recomposition();
315 first.record_recomposition();
316 first.record_frame();
317 second.record_frame();
318
319 let first_stats = first.stats();
320 let second_stats = second.stats();
321
322 assert_eq!(first_stats.recompositions, 2);
323 assert_eq!(second_stats.recompositions, 0);
324 assert_eq!(first_stats.frame_count, 1);
325 assert_eq!(second_stats.frame_count, 1);
326 }
327
328 #[test]
329 fn nearest_rank_percentile_reports_tail_samples() {
330 let samples = [1.0, 2.0, 3.0, 40.0];
331
332 assert_eq!(nearest_rank_percentile(&samples, 50), 2.0);
333 assert_eq!(nearest_rank_percentile(&samples, 95), 40.0);
334 assert_eq!(nearest_rank_percentile(&samples, 99), 40.0);
335 }
336
337 #[test]
338 fn frame_stats_report_pacing_jank_not_just_average_fps() {
339 let mut tracker = FpsTracker::new();
340 let start = web_time::Instant::now();
341 let offsets = [0u64, 8, 16, 24, 64, 72];
342
343 for offset in offsets {
344 tracker.record_frame_at(start + Duration::from_millis(offset), 0);
345 }
346
347 let stats = tracker.stats(0);
348
349 assert_eq!(stats.interval_count, 5);
350 assert_eq!(stats.frame_count, offsets.len() as u64);
351 assert!((stats.latest_ms - 8.0).abs() < 0.1);
352 assert!((stats.max_ms - 40.0).abs() < 0.1);
353 assert!((stats.p95_ms - 40.0).abs() < 0.1);
354 assert_eq!(stats.missed_120hz_budget, 1);
355 assert_eq!(stats.missed_60hz_budget, 1);
356 assert_eq!(stats.stalled_50ms_frames, 0);
357 assert!(
358 stats.fps > 60.0,
359 "average FPS can stay plausible while the p95 frame is bad"
360 );
361 }
362
363 #[test]
364 fn frame_stats_report_frame_work_separately_from_pacing_gaps() {
365 let mut tracker = FpsTracker::new();
366 let start = web_time::Instant::now();
367 let starts = [0u64, 40, 80];
368 let work = [2u64, 3, 4];
369
370 for (start_offset, work_ms) in starts.into_iter().zip(work) {
371 let frame_start = start + Duration::from_millis(start_offset);
372 let frame_end = frame_start + Duration::from_millis(work_ms);
373 tracker.record_frame_work(frame_start, frame_end, 0);
374 }
375
376 let stats = tracker.stats(0);
377
378 assert!((stats.p95_ms - 40.0).abs() < 0.1);
379 assert!((stats.work_avg_ms - 3.0).abs() < 0.1);
380 assert!((stats.work_p95_ms - 4.0).abs() < 0.1);
381 assert!((stats.work_max_ms - 4.0).abs() < 0.1);
382 }
383
384 #[test]
385 fn reset_stats_drops_idle_gap_before_measurement_window() {
386 let mut tracker = FpsTracker::new();
387 let start = web_time::Instant::now();
388
389 tracker.record_frame_at(start, 3);
390 tracker.record_frame_at(start + Duration::from_secs(4), 3);
391 assert!(tracker.stats(3).max_ms > 3000.0);
392
393 tracker.reset(3);
394 tracker.record_frame_at(start + Duration::from_secs(4) + Duration::from_millis(8), 3);
395 tracker.record_frame_at(
396 start + Duration::from_secs(4) + Duration::from_millis(16),
397 3,
398 );
399
400 let stats = tracker.stats(3);
401 assert_eq!(stats.frame_count, 2);
402 assert_eq!(stats.interval_count, 1);
403 assert!((stats.max_ms - 8.0).abs() < 0.1);
404 assert_eq!(stats.recomps_per_second, 0);
405 }
406}