Skip to main content

cranpose_app_shell/
fps_monitor.rs

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    cranpose_core::DIAGNOSTICS.recomp_diag.is_set()
10}
11
12fn recomposition_diagnostic_line(rate: u64, frames_per_second: f32, total: u64) -> String {
13    format!("[recomp] {rate}/s frames={frames_per_second:.1}/s total={total}")
14}
15
16#[derive(Debug)]
17pub(crate) struct FpsMonitor {
18    tracker: FpsTracker,
19    recomposition_count: u64,
20    recomposition_reset_baseline: u64,
21}
22
23impl FpsMonitor {
24    pub(crate) fn new() -> Self {
25        Self {
26            tracker: FpsTracker::new(),
27            recomposition_count: 0,
28            recomposition_reset_baseline: 0,
29        }
30    }
31
32    #[cfg(test)]
33    pub(crate) fn record_frame(&mut self) {
34        self.tracker.record_frame(self.recomposition_count);
35    }
36
37    pub(crate) fn record_frame_work(
38        &mut self,
39        frame_started_at: Instant,
40        frame_finished_at: Instant,
41    ) {
42        self.tracker.record_frame_work(
43            frame_started_at,
44            frame_finished_at,
45            self.recomposition_count,
46        );
47    }
48
49    pub(crate) fn record_recomposition(&mut self) {
50        self.recomposition_count = self.recomposition_count.saturating_add(1);
51    }
52
53    pub(crate) fn reset_stats(&mut self) {
54        self.tracker.reset(self.recomposition_count);
55        self.recomposition_reset_baseline = self.recomposition_count;
56    }
57
58    pub(crate) fn current_fps(&self) -> f32 {
59        self.tracker.last_fps
60    }
61
62    pub(crate) fn stats(&self) -> FpsStats {
63        self.tracker.stats(
64            self.recomposition_count
65                .saturating_sub(self.recomposition_reset_baseline),
66        )
67    }
68}
69
70impl Default for FpsMonitor {
71    fn default() -> Self {
72        Self::new()
73    }
74}
75
76#[derive(Debug)]
77struct FpsTracker {
78    frame_times: VecDeque<Instant>,
79    frame_intervals_ms: VecDeque<f32>,
80    frame_work_ms: VecDeque<f32>,
81    last_fps: f32,
82    frame_count: u64,
83    intervals: FrameIntervalStats,
84    work: FrameIntervalStats,
85    last_recomp_count: u64,
86    recomps_per_second: u64,
87    last_recomp_calc: Instant,
88}
89
90impl FpsTracker {
91    fn new() -> Self {
92        Self {
93            frame_times: VecDeque::with_capacity(FRAME_HISTORY_SIZE + 1),
94            frame_intervals_ms: VecDeque::with_capacity(FRAME_HISTORY_SIZE),
95            frame_work_ms: VecDeque::with_capacity(FRAME_HISTORY_SIZE),
96            last_fps: 0.0,
97            frame_count: 0,
98            intervals: FrameIntervalStats::default(),
99            work: FrameIntervalStats::default(),
100            last_recomp_count: 0,
101            recomps_per_second: 0,
102            last_recomp_calc: Instant::now(),
103        }
104    }
105
106    #[cfg(test)]
107    fn record_frame(&mut self, recomposition_count: u64) {
108        let now = Instant::now();
109        self.record_frame_work(now, now, recomposition_count);
110    }
111
112    #[cfg(test)]
113    fn record_frame_at(&mut self, now: Instant, recomposition_count: u64) {
114        self.record_frame_work(now, now, recomposition_count);
115    }
116
117    fn reset(&mut self, recomposition_count: u64) {
118        self.frame_times.clear();
119        self.frame_intervals_ms.clear();
120        self.frame_work_ms.clear();
121        self.last_fps = 0.0;
122        self.frame_count = 0;
123        self.intervals = FrameIntervalStats::default();
124        self.work = FrameIntervalStats::default();
125        self.last_recomp_count = recomposition_count;
126        self.recomps_per_second = 0;
127        self.last_recomp_calc = Instant::now();
128    }
129
130    fn record_frame_work(
131        &mut self,
132        frame_started_at: Instant,
133        frame_finished_at: Instant,
134        recomposition_count: u64,
135    ) {
136        if let Some(previous) = self.frame_times.back() {
137            let interval_ms = frame_started_at.duration_since(*previous).as_secs_f32() * 1000.0;
138            if interval_ms <= EVENT_DRIVEN_IDLE_GAP_MS {
139                self.frame_intervals_ms.push_back(interval_ms);
140                while self.frame_intervals_ms.len() > FRAME_HISTORY_SIZE {
141                    self.frame_intervals_ms.pop_front();
142                }
143                self.intervals = FrameIntervalStats::from_samples(&self.frame_intervals_ms);
144                self.last_fps = fps_from_avg_ms(self.intervals.avg_ms);
145            }
146        }
147
148        let work_ms = frame_finished_at
149            .duration_since(frame_started_at)
150            .as_secs_f32()
151            * 1000.0;
152        self.frame_work_ms.push_back(work_ms);
153        while self.frame_work_ms.len() > FRAME_HISTORY_SIZE {
154            self.frame_work_ms.pop_front();
155        }
156        self.work = FrameIntervalStats::from_samples(&self.frame_work_ms);
157
158        self.frame_times.push_back(frame_started_at);
159        self.frame_count += 1;
160
161        while self.frame_times.len() > FRAME_HISTORY_SIZE + 1 {
162            self.frame_times.pop_front();
163        }
164
165        let elapsed = frame_finished_at
166            .duration_since(self.last_recomp_calc)
167            .as_secs_f32();
168        if elapsed >= 1.0 {
169            self.recomps_per_second = recomposition_count.saturating_sub(self.last_recomp_count);
170            self.last_recomp_count = recomposition_count;
171            self.last_recomp_calc = frame_finished_at;
172            if recomposition_diag_enabled() {
173                log::info!(
174                    target: "cranpose::recomposition",
175                    "{}",
176                    recomposition_diagnostic_line(
177                        self.recomps_per_second,
178                        self.last_fps,
179                        recomposition_count,
180                    )
181                );
182            }
183        }
184    }
185
186    fn stats(&self, recomposition_count: u64) -> FpsStats {
187        FpsStats {
188            fps: self.last_fps,
189            avg_ms: self.intervals.avg_ms,
190            latest_ms: self.intervals.latest_ms,
191            min_ms: self.intervals.min_ms,
192            max_ms: self.intervals.max_ms,
193            p95_ms: self.intervals.p95_ms,
194            p99_ms: self.intervals.p99_ms,
195            work_fps: fps_from_avg_ms(self.work.avg_ms),
196            work_avg_ms: self.work.avg_ms,
197            work_p95_ms: self.work.p95_ms,
198            work_max_ms: self.work.max_ms,
199            work_missed_120hz_budget: self.work.missed_120hz_budget,
200            work_missed_60hz_budget: self.work.missed_60hz_budget,
201            work_stalled_50ms_frames: self.work.stalled_50ms_frames,
202            interval_count: self.intervals.count,
203            missed_120hz_budget: self.intervals.missed_120hz_budget,
204            missed_60hz_budget: self.intervals.missed_60hz_budget,
205            stalled_50ms_frames: self.intervals.stalled_50ms_frames,
206            frame_count: self.frame_count,
207            recompositions: recomposition_count,
208            recomps_per_second: self.recomps_per_second,
209        }
210    }
211}
212
213#[derive(Clone, Copy, Debug, Default)]
214struct FrameIntervalStats {
215    count: u32,
216    latest_ms: f32,
217    avg_ms: f32,
218    min_ms: f32,
219    max_ms: f32,
220    p95_ms: f32,
221    p99_ms: f32,
222    missed_120hz_budget: u32,
223    missed_60hz_budget: u32,
224    stalled_50ms_frames: u32,
225}
226
227impl FrameIntervalStats {
228    const FRAME_120HZ_MS: f32 = 1000.0 / 120.0;
229    const FRAME_60HZ_MS: f32 = 1000.0 / 60.0;
230    const STALL_MS: f32 = 50.0;
231
232    fn from_samples(samples: &VecDeque<f32>) -> Self {
233        let count = samples.len();
234        if count == 0 {
235            return Self::default();
236        }
237
238        let mut sorted = [0.0f32; FRAME_HISTORY_SIZE];
239        let mut sum = 0.0f32;
240        let mut min_ms = f32::INFINITY;
241        let mut max_ms = 0.0f32;
242        let mut missed_120hz_budget = 0u32;
243        let mut missed_60hz_budget = 0u32;
244        let mut stalled_50ms_frames = 0u32;
245
246        for (index, interval_ms) in samples.iter().copied().enumerate() {
247            sorted[index] = interval_ms;
248            sum += interval_ms;
249            min_ms = min_ms.min(interval_ms);
250            max_ms = max_ms.max(interval_ms);
251            if interval_ms > Self::FRAME_120HZ_MS {
252                missed_120hz_budget = missed_120hz_budget.saturating_add(1);
253            }
254            if interval_ms > Self::FRAME_60HZ_MS {
255                missed_60hz_budget = missed_60hz_budget.saturating_add(1);
256            }
257            if interval_ms > Self::STALL_MS {
258                stalled_50ms_frames = stalled_50ms_frames.saturating_add(1);
259            }
260        }
261
262        let sorted = &mut sorted[..count];
263        sorted.sort_by(f32::total_cmp);
264
265        Self {
266            count: count as u32,
267            latest_ms: samples.back().copied().unwrap_or_default(),
268            avg_ms: sum / count as f32,
269            min_ms,
270            max_ms,
271            p95_ms: nearest_rank_percentile(sorted, 95),
272            p99_ms: nearest_rank_percentile(sorted, 99),
273            missed_120hz_budget,
274            missed_60hz_budget,
275            stalled_50ms_frames,
276        }
277    }
278}
279
280fn fps_from_avg_ms(avg_ms: f32) -> f32 {
281    if avg_ms > 0.0 { 1000.0 / avg_ms } else { 0.0 }
282}
283
284fn nearest_rank_percentile(sorted_samples: &[f32], percentile: usize) -> f32 {
285    if sorted_samples.is_empty() {
286        return 0.0;
287    }
288    let rank = sorted_samples
289        .len()
290        .saturating_mul(percentile)
291        .div_ceil(100)
292        .saturating_sub(1);
293    sorted_samples[rank.min(sorted_samples.len() - 1)]
294}
295
296/// Frame statistics snapshot.
297#[derive(Clone, Copy, Debug, Default)]
298pub struct FpsStats {
299    /// Current presented-frame cadence in frames per second.
300    pub fps: f32,
301    /// Average presented-frame interval in milliseconds.
302    pub avg_ms: f32,
303    /// Last recorded frame interval in milliseconds.
304    pub latest_ms: f32,
305    /// Minimum frame interval in the rolling history.
306    pub min_ms: f32,
307    /// Maximum frame interval in the rolling history.
308    pub max_ms: f32,
309    /// 95th percentile frame interval in the rolling history.
310    pub p95_ms: f32,
311    /// 99th percentile frame interval in the rolling history.
312    pub p99_ms: f32,
313    /// AppShell work capacity in frames per second.
314    pub work_fps: f32,
315    /// Average measured AppShell frame work in milliseconds.
316    pub work_avg_ms: f32,
317    /// 95th percentile measured AppShell frame work in milliseconds.
318    pub work_p95_ms: f32,
319    /// Maximum measured AppShell frame work in milliseconds.
320    pub work_max_ms: f32,
321    /// Rolling count of measured AppShell work samples above the 120 Hz frame budget.
322    pub work_missed_120hz_budget: u32,
323    /// Rolling count of measured AppShell work samples above the 60 Hz frame budget.
324    pub work_missed_60hz_budget: u32,
325    /// Rolling count of measured AppShell work samples above 50 ms.
326    pub work_stalled_50ms_frames: u32,
327    /// Number of frame intervals in the rolling history.
328    pub interval_count: u32,
329    /// Rolling count of intervals above the 120 Hz frame budget.
330    pub missed_120hz_budget: u32,
331    /// Rolling count of intervals above the 60 Hz frame budget.
332    pub missed_60hz_budget: u32,
333    /// Rolling count of intervals above 50 ms.
334    pub stalled_50ms_frames: u32,
335    /// Total frame count since monitor creation.
336    pub frame_count: u64,
337    /// Recomposition count since the last stats reset.
338    pub recompositions: u64,
339    /// Recompositions in the last second.
340    pub recomps_per_second: u64,
341}
342
343#[cfg(test)]
344#[path = "tests/fps_monitor_tests.rs"]
345mod tests;