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