retroglyph_core/frames/stats.rs
1//! Rolling frame-time statistics for a live perf/FPS overlay.
2//!
3//! [`FrameStats`](crate::frames::FrameStats) is a fixed-size ring buffer fed one sample per frame via
4//! [`record`](crate::frames::FrameStats::record), decoupled from any renderer or backend the same way
5//! [`FrameClock`](crate::frames::FrameClock) is: it only consumes wall time handed to it
6//! (typically [`Frame::delta`](crate::app::Frame)), never reads a clock itself, so it stays
7//! `no_std`-clean and platform-agnostic (including wasm, where there is no `std::time::Instant`).
8//!
9//! # Example
10//!
11//! ```
12//! use core::time::Duration;
13//! use retroglyph_core::frames::FrameStats;
14//!
15//! let mut stats: FrameStats = FrameStats::new(); // 120-frame window by default
16//! for _ in 0..30 {
17//! stats.record(Duration::from_millis(16));
18//! }
19//! assert!((stats.fps() - 62.5).abs() < 0.5);
20//! assert!((stats.avg().as_millis() as i64 - 16).abs() <= 1);
21//! ```
22
23use core::time::Duration;
24
25/// A fixed-size ring buffer of recent per-frame durations, plus the min/max/average/fps readouts
26/// derived from it.
27///
28/// `N` bounds memory and how far back the window looks; the default, 120 samples (about two
29/// seconds at 60fps), is a reasonable window for a live overlay. Every reducer
30/// ([`avg`](Self::avg), [`min`](Self::min), [`max`](Self::max), [`fps`](Self::fps)) is computed
31/// on demand from the current window rather than maintained incrementally, since a live overlay
32/// only calls them once per rendered frame, not once per sample.
33///
34/// Readouts are [`Duration`], not a pre-chosen unit: a caller formats with whatever precision it
35/// needs (`as_millis()`, `as_secs_f32()`, ...) rather than being handed milliseconds it then has
36/// to convert back if it wants something else. [`current`](Self::current) is the single most
37/// recent sample, unsmoothed: pair it with the windowed [`min`](Self::min)/[`max`](Self::max) for
38/// a "current, min, max" readout, and [`samples`](Self::samples) for a frame-time graph (feed it,
39/// converted to milliseconds, straight into
40/// [`Sparkline`](https://docs.rs/retroglyph-ui/latest/retroglyph_ui/struct.Sparkline.html)).
41#[derive(Debug, Clone)]
42pub struct FrameStats<const N: usize = 120> {
43 /// Ring buffer of frame durations.
44 samples: [Duration; N],
45 /// Number of valid entries in `samples` (`0..=N`); reaches `N` once the ring has wrapped.
46 len: usize,
47 /// Index the next [`record`](Self::record) call writes to.
48 head: usize,
49 /// Total frames ever recorded, uncapped by `N`.
50 frames: u64,
51}
52
53impl<const N: usize> Default for FrameStats<N> {
54 fn default() -> Self {
55 Self::new()
56 }
57}
58
59impl<const N: usize> FrameStats<N> {
60 /// An empty window; every readout is [`Duration::ZERO`] (or `0.0` for [`fps`](Self::fps))
61 /// until the first [`record`](Self::record).
62 #[must_use]
63 pub const fn new() -> Self {
64 Self {
65 samples: [Duration::ZERO; N],
66 len: 0,
67 head: 0,
68 frames: 0,
69 }
70 }
71
72 /// Records one frame's wall-clock duration.
73 ///
74 /// Call once per rendered frame with [`Frame::delta`](crate::app::Frame::delta). A no-op if
75 /// `N` is `0` (frame count still advances) rather than panicking: a zero-capacity window is
76 /// a degenerate but harmless configuration, not a caller error worth crashing over.
77 pub fn record(&mut self, delta: Duration) {
78 self.frames = self.frames.wrapping_add(1);
79 if N == 0 {
80 return;
81 }
82 self.samples[self.head] = delta;
83 self.head = (self.head + 1) % N;
84 self.len = (self.len + 1).min(N);
85 }
86
87 /// Total frames ever recorded via [`record`](Self::record), uncapped by `N`.
88 #[must_use]
89 pub const fn frame_count(&self) -> u64 {
90 self.frames
91 }
92
93 /// The window's starting index into `samples` (oldest sample first): `0` while the ring
94 /// hasn't wrapped yet, `head` (the slot about to be overwritten next) once it has.
95 const fn start(&self) -> usize {
96 if self.len < N { 0 } else { self.head }
97 }
98
99 /// Recorded frame durations, oldest first (so the most recent frame is last): the order a
100 /// sparkline/histogram wants so new data enters on the right.
101 #[must_use]
102 pub fn samples(&self) -> impl ExactSizeIterator<Item = Duration> + '_ {
103 let start = self.start();
104 let len = self.len;
105 // `N` is `0` only in the degenerate case handled by `record`'s early return, in which
106 // case `len` is always `0` too and this range never indexes `samples`.
107 let modulus = if N == 0 { 1 } else { N };
108 (0..len).map(move |i| self.samples[(start + i) % modulus])
109 }
110
111 /// The most recently recorded frame's duration, unsmoothed. [`Duration::ZERO`] before the
112 /// first [`record`](Self::record).
113 #[must_use]
114 pub const fn current(&self) -> Duration {
115 if self.len == 0 || N == 0 {
116 return Duration::ZERO;
117 }
118 self.samples[(self.head + N - 1) % N]
119 }
120
121 /// The average frame duration over the current window. [`Duration::ZERO`] before the first
122 /// [`record`](Self::record).
123 #[must_use]
124 pub fn avg(&self) -> Duration {
125 if self.len == 0 {
126 return Duration::ZERO;
127 }
128 // `len` is at most `N`, a small fixed window (hundreds of samples at most), so it always
129 // fits a `u32` divisor.
130 #[allow(clippy::cast_possible_truncation)]
131 let len = self.len as u32;
132 self.samples().sum::<Duration>() / len
133 }
134
135 /// The fastest (shortest) frame in the current window. [`Duration::ZERO`] before the first
136 /// [`record`](Self::record).
137 #[must_use]
138 pub fn min(&self) -> Duration {
139 self.samples().min().unwrap_or_default()
140 }
141
142 /// The slowest (longest) frame in the current window. [`Duration::ZERO`] before the first
143 /// [`record`](Self::record).
144 #[must_use]
145 pub fn max(&self) -> Duration {
146 self.samples().max().unwrap_or_default()
147 }
148
149 /// Frames per second, derived from [`avg`](Self::avg) over the current window. `0.0` before
150 /// the first [`record`](Self::record) (rather than dividing by zero).
151 #[must_use]
152 pub fn fps(&self) -> f32 {
153 let avg = self.avg().as_secs_f32();
154 if avg <= 0.0 { 0.0 } else { 1.0 / avg }
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 fn settled<const N: usize>(millis: u64, frames: usize) -> FrameStats<N> {
163 let mut stats = FrameStats::new();
164 for _ in 0..frames {
165 stats.record(Duration::from_millis(millis));
166 }
167 stats
168 }
169
170 #[test]
171 fn empty_stats_read_as_zero() {
172 let stats = FrameStats::<8>::new();
173 assert_eq!(stats.frame_count(), 0);
174 assert_eq!(stats.current(), Duration::ZERO);
175 assert_eq!(stats.avg(), Duration::ZERO);
176 assert_eq!(stats.min(), Duration::ZERO);
177 assert_eq!(stats.max(), Duration::ZERO);
178 assert!((stats.fps() - 0.0).abs() < f32::EPSILON);
179 assert_eq!(stats.samples().count(), 0);
180 }
181
182 #[test]
183 fn steady_frames_report_consistent_stats() {
184 let stats = settled::<8>(16, 5);
185 assert_eq!(stats.frame_count(), 5);
186 assert_eq!(stats.current(), Duration::from_millis(16));
187 assert_eq!(stats.avg(), Duration::from_millis(16));
188 assert_eq!(stats.min(), Duration::from_millis(16));
189 assert_eq!(stats.max(), Duration::from_millis(16));
190 assert!((stats.fps() - 62.5).abs() < 0.1);
191 assert_eq!(stats.samples().count(), 5);
192 }
193
194 #[test]
195 fn ring_buffer_wraps_and_drops_the_oldest_sample() {
196 use alloc::vec::Vec;
197
198 let mut stats = FrameStats::<3>::new();
199 for ms in [10, 20, 30, 40] {
200 stats.record(Duration::from_millis(ms));
201 }
202 // Capacity 3, 4 samples recorded: the oldest (10ms) fell off the window.
203 assert_eq!(stats.frame_count(), 4);
204 let samples: Vec<Duration> = stats.samples().collect();
205 assert_eq!(
206 samples,
207 [10, 20, 30, 40][1..]
208 .iter()
209 .map(|&ms| Duration::from_millis(ms))
210 .collect::<Vec<_>>()
211 );
212 assert_eq!(stats.current(), Duration::from_millis(40));
213 assert_eq!(stats.min(), Duration::from_millis(20));
214 assert_eq!(stats.max(), Duration::from_millis(40));
215 }
216
217 #[test]
218 fn min_and_max_track_the_extremes_of_a_varying_window() {
219 let mut stats = FrameStats::<8>::new();
220 for ms in [16, 16, 40, 16, 8, 16] {
221 stats.record(Duration::from_millis(ms));
222 }
223 assert_eq!(stats.min(), Duration::from_millis(8));
224 assert_eq!(stats.max(), Duration::from_millis(40));
225 assert_eq!(stats.current(), Duration::from_millis(16));
226 }
227
228 #[test]
229 fn zero_capacity_window_still_counts_frames_without_panicking() {
230 let mut stats = FrameStats::<0>::new();
231 stats.record(Duration::from_millis(16));
232 stats.record(Duration::from_millis(16));
233 assert_eq!(stats.frame_count(), 2);
234 assert_eq!(stats.avg(), Duration::ZERO);
235 assert_eq!(stats.samples().count(), 0);
236 }
237
238 #[test]
239 fn default_matches_new() {
240 let stats: FrameStats<4> = FrameStats::default();
241 assert_eq!(stats.frame_count(), 0);
242 }
243
244 /// The whole point of returning [`Duration`] instead of a pre-chosen unit (milliseconds, as
245 /// an earlier revision of this API did): sub-millisecond precision survives untouched.
246 /// Rounding to whole milliseconds internally, the way an `f32`-milliseconds readout would
247 /// tend to invite, would make this fail.
248 #[test]
249 fn sub_millisecond_precision_is_not_rounded_away() {
250 let mut stats = FrameStats::<4>::new();
251 stats.record(Duration::from_micros(1500)); // 1.5ms, not representable as whole ms
252 assert_eq!(stats.current(), Duration::from_micros(1500));
253 assert_eq!(stats.avg(), Duration::from_micros(1500));
254 assert_eq!(stats.min(), Duration::from_micros(1500));
255 assert_eq!(stats.max(), Duration::from_micros(1500));
256 }
257
258 #[test]
259 fn fps_reflects_a_varying_not_just_steady_window() {
260 let mut stats = FrameStats::<4>::new();
261 // Two 10ms frames and two 30ms frames: average 20ms -> 50fps, not the steady-state
262 // 1000/10=100 or 1000/30=33 either extreme would give.
263 for ms in [10, 30, 10, 30] {
264 stats.record(Duration::from_millis(ms));
265 }
266 assert!((stats.fps() - 50.0).abs() < 0.1, "fps={}", stats.fps());
267 }
268}