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