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