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#[derive(Clone, Copy, Debug, Default)]
298pub struct FpsStats {
299 pub fps: f32,
301 pub avg_ms: f32,
303 pub latest_ms: f32,
305 pub min_ms: f32,
307 pub max_ms: f32,
309 pub p95_ms: f32,
311 pub p99_ms: f32,
313 pub work_fps: f32,
315 pub work_avg_ms: f32,
317 pub work_p95_ms: f32,
319 pub work_max_ms: f32,
321 pub work_missed_120hz_budget: u32,
323 pub work_missed_60hz_budget: u32,
325 pub work_stalled_50ms_frames: u32,
327 pub interval_count: u32,
329 pub missed_120hz_budget: u32,
331 pub missed_60hz_budget: u32,
333 pub stalled_50ms_frames: u32,
335 pub frame_count: u64,
337 pub recompositions: u64,
339 pub recomps_per_second: u64,
341}
342
343#[cfg(test)]
344#[path = "tests/fps_monitor_tests.rs"]
345mod tests;