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