1use std::collections::VecDeque;
4use web_time::Instant;
5
6const FRAME_HISTORY_SIZE: usize = 60;
7const EVENT_DRIVEN_IDLE_GAP_MS: f32 = 50.0;
8
9#[derive(Debug)]
10pub(crate) struct FpsMonitor {
11 tracker: FpsTracker,
12 recomposition_count: u64,
13 recomposition_reset_baseline: u64,
14}
15
16impl FpsMonitor {
17 pub(crate) fn new() -> Self {
18 Self {
19 tracker: FpsTracker::new(),
20 recomposition_count: 0,
21 recomposition_reset_baseline: 0,
22 }
23 }
24
25 #[cfg(test)]
26 pub(crate) fn record_frame(&mut self) {
27 self.tracker.record_frame(self.recomposition_count);
28 }
29
30 pub(crate) fn record_frame_work(
31 &mut self,
32 frame_started_at: Instant,
33 frame_finished_at: Instant,
34 ) {
35 self.tracker.record_frame_work(
36 frame_started_at,
37 frame_finished_at,
38 self.recomposition_count,
39 );
40 }
41
42 pub(crate) fn record_recomposition(&mut self) {
43 self.recomposition_count = self.recomposition_count.saturating_add(1);
44 }
45
46 pub(crate) fn reset_stats(&mut self) {
47 self.tracker.reset(self.recomposition_count);
48 self.recomposition_reset_baseline = self.recomposition_count;
49 }
50
51 pub(crate) fn current_fps(&self) -> f32 {
52 self.tracker.last_fps
53 }
54
55 pub(crate) fn stats(&self) -> FpsStats {
56 self.tracker.stats(
57 self.recomposition_count
58 .saturating_sub(self.recomposition_reset_baseline),
59 )
60 }
61}
62
63impl Default for FpsMonitor {
64 fn default() -> Self {
65 Self::new()
66 }
67}
68
69#[derive(Debug)]
70struct FpsTracker {
71 frame_times: VecDeque<Instant>,
72 frame_intervals_ms: VecDeque<f32>,
73 frame_work_ms: VecDeque<f32>,
74 last_fps: f32,
75 frame_count: u64,
76 intervals: FrameIntervalStats,
77 work: FrameIntervalStats,
78 last_recomp_count: u64,
79 recomps_per_second: u64,
80 last_recomp_calc: Instant,
81}
82
83impl FpsTracker {
84 fn new() -> Self {
85 Self {
86 frame_times: VecDeque::with_capacity(FRAME_HISTORY_SIZE + 1),
87 frame_intervals_ms: VecDeque::with_capacity(FRAME_HISTORY_SIZE),
88 frame_work_ms: VecDeque::with_capacity(FRAME_HISTORY_SIZE),
89 last_fps: 0.0,
90 frame_count: 0,
91 intervals: FrameIntervalStats::default(),
92 work: FrameIntervalStats::default(),
93 last_recomp_count: 0,
94 recomps_per_second: 0,
95 last_recomp_calc: Instant::now(),
96 }
97 }
98
99 #[cfg(test)]
100 fn record_frame(&mut self, recomposition_count: u64) {
101 let now = Instant::now();
102 self.record_frame_work(now, now, recomposition_count);
103 }
104
105 #[cfg(test)]
106 fn record_frame_at(&mut self, now: Instant, recomposition_count: u64) {
107 self.record_frame_work(now, now, recomposition_count);
108 }
109
110 fn reset(&mut self, recomposition_count: u64) {
111 self.frame_times.clear();
112 self.frame_intervals_ms.clear();
113 self.frame_work_ms.clear();
114 self.last_fps = 0.0;
115 self.frame_count = 0;
116 self.intervals = FrameIntervalStats::default();
117 self.work = FrameIntervalStats::default();
118 self.last_recomp_count = recomposition_count;
119 self.recomps_per_second = 0;
120 self.last_recomp_calc = Instant::now();
121 }
122
123 fn record_frame_work(
124 &mut self,
125 frame_started_at: Instant,
126 frame_finished_at: Instant,
127 recomposition_count: u64,
128 ) {
129 if let Some(previous) = self.frame_times.back() {
130 let interval_ms = frame_started_at.duration_since(*previous).as_secs_f32() * 1000.0;
131 if interval_ms <= EVENT_DRIVEN_IDLE_GAP_MS {
132 self.frame_intervals_ms.push_back(interval_ms);
133 while self.frame_intervals_ms.len() > FRAME_HISTORY_SIZE {
134 self.frame_intervals_ms.pop_front();
135 }
136 self.intervals = FrameIntervalStats::from_samples(&self.frame_intervals_ms);
137 self.last_fps = fps_from_avg_ms(self.intervals.avg_ms);
138 }
139 }
140
141 let work_ms = frame_finished_at
142 .duration_since(frame_started_at)
143 .as_secs_f32()
144 * 1000.0;
145 self.frame_work_ms.push_back(work_ms);
146 while self.frame_work_ms.len() > FRAME_HISTORY_SIZE {
147 self.frame_work_ms.pop_front();
148 }
149 self.work = FrameIntervalStats::from_samples(&self.frame_work_ms);
150
151 self.frame_times.push_back(frame_started_at);
152 self.frame_count += 1;
153
154 while self.frame_times.len() > FRAME_HISTORY_SIZE + 1 {
155 self.frame_times.pop_front();
156 }
157
158 let elapsed = frame_finished_at
159 .duration_since(self.last_recomp_calc)
160 .as_secs_f32();
161 if elapsed >= 1.0 {
162 self.recomps_per_second = recomposition_count.saturating_sub(self.last_recomp_count);
163 self.last_recomp_count = recomposition_count;
164 self.last_recomp_calc = frame_finished_at;
165 }
166 }
167
168 fn stats(&self, recomposition_count: u64) -> FpsStats {
169 FpsStats {
170 fps: self.last_fps,
171 avg_ms: self.intervals.avg_ms,
172 latest_ms: self.intervals.latest_ms,
173 min_ms: self.intervals.min_ms,
174 max_ms: self.intervals.max_ms,
175 p95_ms: self.intervals.p95_ms,
176 p99_ms: self.intervals.p99_ms,
177 work_fps: fps_from_avg_ms(self.work.avg_ms),
178 work_avg_ms: self.work.avg_ms,
179 work_p95_ms: self.work.p95_ms,
180 work_max_ms: self.work.max_ms,
181 work_missed_120hz_budget: self.work.missed_120hz_budget,
182 work_missed_60hz_budget: self.work.missed_60hz_budget,
183 work_stalled_50ms_frames: self.work.stalled_50ms_frames,
184 interval_count: self.intervals.count,
185 missed_120hz_budget: self.intervals.missed_120hz_budget,
186 missed_60hz_budget: self.intervals.missed_60hz_budget,
187 stalled_50ms_frames: self.intervals.stalled_50ms_frames,
188 frame_count: self.frame_count,
189 recompositions: recomposition_count,
190 recomps_per_second: self.recomps_per_second,
191 }
192 }
193}
194
195#[derive(Clone, Copy, Debug, Default)]
196struct FrameIntervalStats {
197 count: u32,
198 latest_ms: f32,
199 avg_ms: f32,
200 min_ms: f32,
201 max_ms: f32,
202 p95_ms: f32,
203 p99_ms: f32,
204 missed_120hz_budget: u32,
205 missed_60hz_budget: u32,
206 stalled_50ms_frames: u32,
207}
208
209impl FrameIntervalStats {
210 const FRAME_120HZ_MS: f32 = 1000.0 / 120.0;
211 const FRAME_60HZ_MS: f32 = 1000.0 / 60.0;
212 const STALL_MS: f32 = 50.0;
213
214 fn from_samples(samples: &VecDeque<f32>) -> Self {
215 let count = samples.len();
216 if count == 0 {
217 return Self::default();
218 }
219
220 let mut sorted = [0.0f32; FRAME_HISTORY_SIZE];
221 let mut sum = 0.0f32;
222 let mut min_ms = f32::INFINITY;
223 let mut max_ms = 0.0f32;
224 let mut missed_120hz_budget = 0u32;
225 let mut missed_60hz_budget = 0u32;
226 let mut stalled_50ms_frames = 0u32;
227
228 for (index, interval_ms) in samples.iter().copied().enumerate() {
229 sorted[index] = interval_ms;
230 sum += interval_ms;
231 min_ms = min_ms.min(interval_ms);
232 max_ms = max_ms.max(interval_ms);
233 if interval_ms > Self::FRAME_120HZ_MS {
234 missed_120hz_budget = missed_120hz_budget.saturating_add(1);
235 }
236 if interval_ms > Self::FRAME_60HZ_MS {
237 missed_60hz_budget = missed_60hz_budget.saturating_add(1);
238 }
239 if interval_ms > Self::STALL_MS {
240 stalled_50ms_frames = stalled_50ms_frames.saturating_add(1);
241 }
242 }
243
244 let sorted = &mut sorted[..count];
245 sorted.sort_by(|a, b| a.total_cmp(b));
246
247 Self {
248 count: count as u32,
249 latest_ms: samples.back().copied().unwrap_or_default(),
250 avg_ms: sum / count as f32,
251 min_ms,
252 max_ms,
253 p95_ms: nearest_rank_percentile(sorted, 95),
254 p99_ms: nearest_rank_percentile(sorted, 99),
255 missed_120hz_budget,
256 missed_60hz_budget,
257 stalled_50ms_frames,
258 }
259 }
260}
261
262fn fps_from_avg_ms(avg_ms: f32) -> f32 {
263 if avg_ms > 0.0 {
264 1000.0 / avg_ms
265 } else {
266 0.0
267 }
268}
269
270fn nearest_rank_percentile(sorted_samples: &[f32], percentile: usize) -> f32 {
271 if sorted_samples.is_empty() {
272 return 0.0;
273 }
274 let rank = sorted_samples
275 .len()
276 .saturating_mul(percentile)
277 .div_ceil(100)
278 .saturating_sub(1);
279 sorted_samples[rank.min(sorted_samples.len() - 1)]
280}
281
282#[derive(Clone, Copy, Debug, Default)]
284pub struct FpsStats {
285 pub fps: f32,
287 pub avg_ms: f32,
289 pub latest_ms: f32,
291 pub min_ms: f32,
293 pub max_ms: f32,
295 pub p95_ms: f32,
297 pub p99_ms: f32,
299 pub work_fps: f32,
301 pub work_avg_ms: f32,
303 pub work_p95_ms: f32,
305 pub work_max_ms: f32,
307 pub work_missed_120hz_budget: u32,
309 pub work_missed_60hz_budget: u32,
311 pub work_stalled_50ms_frames: u32,
313 pub interval_count: u32,
315 pub missed_120hz_budget: u32,
317 pub missed_60hz_budget: u32,
319 pub stalled_50ms_frames: u32,
321 pub frame_count: u64,
323 pub recompositions: u64,
325 pub recomps_per_second: u64,
327}
328
329#[cfg(test)]
330mod tests {
331 use super::{nearest_rank_percentile, FpsMonitor, FpsTracker};
332 use std::time::Duration;
333
334 #[test]
335 fn monitors_do_not_share_recomposition_or_frame_counts() {
336 let mut first = FpsMonitor::new();
337 let mut second = FpsMonitor::new();
338
339 first.record_recomposition();
340 first.record_recomposition();
341 first.record_frame();
342 second.record_frame();
343
344 let first_stats = first.stats();
345 let second_stats = second.stats();
346
347 assert_eq!(first_stats.recompositions, 2);
348 assert_eq!(second_stats.recompositions, 0);
349 assert_eq!(first_stats.frame_count, 1);
350 assert_eq!(second_stats.frame_count, 1);
351 }
352
353 #[test]
354 fn reset_stats_reports_recompositions_since_reset() {
355 let mut monitor = FpsMonitor::new();
356 monitor.record_recomposition();
357 monitor.record_recomposition();
358
359 monitor.reset_stats();
360 assert_eq!(monitor.stats().recompositions, 0);
361
362 monitor.record_recomposition();
363 assert_eq!(monitor.stats().recompositions, 1);
364 }
365
366 #[test]
367 fn nearest_rank_percentile_reports_tail_samples() {
368 let samples = [1.0, 2.0, 3.0, 40.0];
369
370 assert_eq!(nearest_rank_percentile(&samples, 50), 2.0);
371 assert_eq!(nearest_rank_percentile(&samples, 95), 40.0);
372 assert_eq!(nearest_rank_percentile(&samples, 99), 40.0);
373 }
374
375 #[test]
376 fn frame_stats_report_pacing_jank_not_just_average_fps() {
377 let mut tracker = FpsTracker::new();
378 let start = web_time::Instant::now();
379 let offsets = [0u64, 8, 16, 24, 64, 72];
380
381 for offset in offsets {
382 tracker.record_frame_at(start + Duration::from_millis(offset), 0);
383 }
384
385 let stats = tracker.stats(0);
386
387 assert_eq!(stats.interval_count, 5);
388 assert_eq!(stats.frame_count, offsets.len() as u64);
389 assert!((stats.latest_ms - 8.0).abs() < 0.1);
390 assert!((stats.max_ms - 40.0).abs() < 0.1);
391 assert!((stats.p95_ms - 40.0).abs() < 0.1);
392 assert_eq!(stats.missed_120hz_budget, 1);
393 assert_eq!(stats.missed_60hz_budget, 1);
394 assert_eq!(stats.stalled_50ms_frames, 0);
395 assert!(
396 stats.fps > 60.0,
397 "average FPS can stay plausible while the p95 frame is bad"
398 );
399 }
400
401 #[test]
402 fn frame_stats_report_frame_work_separately_from_pacing_gaps() {
403 let mut tracker = FpsTracker::new();
404 let start = web_time::Instant::now();
405 let starts = [0u64, 40, 80];
406 let work = [2u64, 3, 4];
407
408 for (start_offset, work_ms) in starts.into_iter().zip(work) {
409 let frame_start = start + Duration::from_millis(start_offset);
410 let frame_end = frame_start + Duration::from_millis(work_ms);
411 tracker.record_frame_work(frame_start, frame_end, 0);
412 }
413
414 let stats = tracker.stats(0);
415
416 assert!((stats.p95_ms - 40.0).abs() < 0.1);
417 assert!((stats.work_avg_ms - 3.0).abs() < 0.1);
418 assert!((stats.work_p95_ms - 4.0).abs() < 0.1);
419 assert!((stats.work_max_ms - 4.0).abs() < 0.1);
420 assert_eq!(stats.missed_120hz_budget, 2);
421 assert_eq!(stats.work_missed_120hz_budget, 0);
422 assert!(
423 stats.work_fps > 300.0,
424 "work FPS must measure renderer capacity, not input cadence: {stats:?}"
425 );
426 }
427
428 #[test]
429 fn reset_stats_drops_active_history_before_measurement_window() {
430 let mut tracker = FpsTracker::new();
431 let start = web_time::Instant::now();
432
433 tracker.record_frame_at(start, 3);
434 tracker.record_frame_at(start + Duration::from_millis(8), 3);
435 tracker.record_frame_at(start + Duration::from_secs(4), 3);
436 let before_reset = tracker.stats(3);
437 assert_eq!(before_reset.interval_count, 1);
438 assert!((before_reset.max_ms - 8.0).abs() < 0.1);
439
440 tracker.reset(3);
441 tracker.record_frame_at(start + Duration::from_secs(4) + Duration::from_millis(8), 3);
442 tracker.record_frame_at(
443 start + Duration::from_secs(4) + Duration::from_millis(16),
444 3,
445 );
446
447 let stats = tracker.stats(3);
448 assert_eq!(stats.frame_count, 2);
449 assert_eq!(stats.interval_count, 1);
450 assert!((stats.max_ms - 8.0).abs() < 0.1);
451 assert_eq!(stats.recomps_per_second, 0);
452 }
453
454 #[test]
455 fn frame_stats_ignore_idle_gap_between_event_driven_frames() {
456 let mut tracker = FpsTracker::new();
457 let start = web_time::Instant::now();
458
459 tracker.record_frame_work(start, start + Duration::from_millis(2), 0);
460 tracker.record_frame_work(
461 start + Duration::from_millis(8),
462 start + Duration::from_millis(10),
463 0,
464 );
465 tracker.record_frame_work(
466 start + Duration::from_secs(4),
467 start + Duration::from_secs(4) + Duration::from_millis(1),
468 0,
469 );
470 tracker.record_frame_work(
471 start + Duration::from_secs(4) + Duration::from_millis(8),
472 start + Duration::from_secs(4) + Duration::from_millis(9),
473 0,
474 );
475
476 let stats = tracker.stats(0);
477
478 assert_eq!(stats.interval_count, 2);
479 assert!(
480 stats.max_ms < 10.0,
481 "idle wait must not be reported as active frame pacing: {stats:?}"
482 );
483 assert!(
484 stats.fps > 120.0,
485 "cheap event-driven frames should report active rendering capacity: {stats:?}"
486 );
487 assert!(
488 stats.work_fps > 500.0,
489 "cheap event-driven work should keep separate capacity stats: {stats:?}"
490 );
491 assert!((stats.work_max_ms - 2.0).abs() < 0.1);
492 assert_eq!(stats.work_missed_120hz_budget, 0);
493 }
494
495 #[test]
496 fn frame_stats_ignore_post_interaction_idle_gap_before_next_redraw() {
497 let mut tracker = FpsTracker::new();
498 let start = web_time::Instant::now();
499
500 tracker.record_frame_work(start, start + Duration::from_millis(3), 0);
501 tracker.record_frame_work(
502 start + Duration::from_millis(8),
503 start + Duration::from_millis(11),
504 0,
505 );
506 tracker.record_frame_work(
507 start + Duration::from_millis(16),
508 start + Duration::from_millis(19),
509 0,
510 );
511 tracker.record_frame_work(
512 start + Duration::from_millis(165),
513 start + Duration::from_millis(168),
514 0,
515 );
516
517 let stats = tracker.stats(0);
518
519 assert_eq!(
520 stats.interval_count, 2,
521 "post-interaction idle gaps must not dilute active redraw cadence: {stats:?}"
522 );
523 assert!((stats.max_ms - 8.0).abs() < 0.1);
524 assert_eq!(stats.stalled_50ms_frames, 0);
525 assert_eq!(stats.work_stalled_50ms_frames, 0);
526 }
527}