Skip to main content

goud_engine/sdk/
debug_overlay.rs

1//! FPS stats debug overlay with rolling window.
2//!
3//! Provides [`DebugOverlay`] for tracking frame timing statistics using a
4//! rolling window of recent frame times. Stats are cached and recomputed
5//! at a configurable interval to minimize per-frame overhead.
6
7use std::collections::VecDeque;
8
9/// Maximum number of frame times stored in the rolling window.
10const DEFAULT_WINDOW_CAPACITY: usize = 120;
11
12// =============================================================================
13// FPS Statistics
14// =============================================================================
15
16/// Frame timing statistics computed from the rolling window.
17///
18/// All FPS values are in frames-per-second; `frame_time_ms` is the most
19/// recent frame time in milliseconds.
20#[derive(Debug, Clone, Copy, Default)]
21#[repr(C)]
22pub struct FpsStats {
23    /// Current (most recent) FPS.
24    pub current_fps: f32,
25    /// Minimum FPS observed in the rolling window.
26    pub min_fps: f32,
27    /// Maximum FPS observed in the rolling window.
28    pub max_fps: f32,
29    /// Average FPS across the rolling window.
30    pub avg_fps: f32,
31    /// Most recent frame time in milliseconds.
32    pub frame_time_ms: f32,
33}
34
35// =============================================================================
36// Overlay Corner
37// =============================================================================
38
39/// Screen corner where the overlay is displayed.
40#[derive(Debug, Clone, Copy, PartialEq, Default)]
41#[repr(C)]
42pub enum OverlayCorner {
43    /// Top-left corner of the screen.
44    #[default]
45    TopLeft = 0,
46    /// Top-right corner of the screen.
47    TopRight = 1,
48    /// Bottom-left corner of the screen.
49    BottomLeft = 2,
50    /// Bottom-right corner of the screen.
51    BottomRight = 3,
52}
53
54// =============================================================================
55// Debug Overlay
56// =============================================================================
57
58/// Tracks frame timing and computes FPS statistics over a rolling window.
59///
60/// Stats are cached and only recomputed every `update_interval` seconds
61/// to avoid impacting frame timing.
62#[derive(Debug, Clone)]
63pub struct DebugOverlay {
64    /// Whether the overlay is enabled.
65    enabled: bool,
66    /// Which corner to display the overlay in.
67    corner: OverlayCorner,
68    /// How often (in seconds) to recompute statistics.
69    update_interval: f32,
70    /// Rolling window of recent frame times (in seconds).
71    frame_times: VecDeque<f32>,
72    /// Cached statistics (recomputed every `update_interval`).
73    cached_stats: FpsStats,
74    /// Time accumulated since the last stats recomputation.
75    time_since_update: f32,
76}
77
78impl DebugOverlay {
79    /// Creates a new overlay with the given stats update interval.
80    ///
81    /// The rolling window holds up to 120 frame-time samples.
82    pub fn new(update_interval: f32) -> Self {
83        Self {
84            enabled: false,
85            corner: OverlayCorner::default(),
86            update_interval,
87            frame_times: VecDeque::with_capacity(DEFAULT_WINDOW_CAPACITY),
88            cached_stats: FpsStats::default(),
89            time_since_update: 0.0,
90        }
91    }
92
93    /// Records a frame and recomputes stats if the update interval has elapsed.
94    pub fn update(&mut self, delta_time: f32) {
95        // Always record frame times so stats are ready when queried.
96        if self.frame_times.len() >= DEFAULT_WINDOW_CAPACITY {
97            self.frame_times.pop_front();
98        }
99        self.frame_times.push_back(delta_time);
100
101        self.time_since_update += delta_time;
102        if self.time_since_update >= self.update_interval {
103            self.time_since_update = 0.0;
104            self.recompute_stats(delta_time);
105        }
106    }
107
108    /// Returns the most recently cached FPS statistics.
109    #[inline]
110    pub fn stats(&self) -> FpsStats {
111        self.cached_stats
112    }
113
114    /// Enables or disables the overlay.
115    #[inline]
116    pub fn set_enabled(&mut self, enabled: bool) {
117        self.enabled = enabled;
118    }
119
120    /// Returns whether the overlay is enabled.
121    #[inline]
122    pub fn is_enabled(&self) -> bool {
123        self.enabled
124    }
125
126    /// Sets the display corner.
127    #[inline]
128    pub fn set_corner(&mut self, corner: OverlayCorner) {
129        self.corner = corner;
130    }
131
132    /// Returns the current display corner.
133    #[inline]
134    pub fn corner(&self) -> OverlayCorner {
135        self.corner
136    }
137
138    /// Sets the stats update interval in seconds.
139    #[inline]
140    pub fn set_update_interval(&mut self, interval: f32) {
141        self.update_interval = interval;
142    }
143
144    // -------------------------------------------------------------------------
145    // Internal helpers
146    // -------------------------------------------------------------------------
147
148    fn recompute_stats(&mut self, current_delta: f32) {
149        if self.frame_times.is_empty() {
150            self.cached_stats = FpsStats::default();
151            return;
152        }
153
154        let mut sum: f32 = 0.0;
155        let mut min_dt = f32::MAX;
156        let mut max_dt: f32 = 0.0;
157
158        for &dt in &self.frame_times {
159            sum += dt;
160            if dt < min_dt {
161                min_dt = dt;
162            }
163            if dt > max_dt {
164                max_dt = dt;
165            }
166        }
167
168        let count = self.frame_times.len() as f32;
169        let avg_dt = sum / count;
170
171        self.cached_stats = FpsStats {
172            current_fps: if current_delta > 0.0 {
173                1.0 / current_delta
174            } else {
175                0.0
176            },
177            min_fps: if max_dt > 0.0 { 1.0 / max_dt } else { 0.0 },
178            max_fps: if min_dt > 0.0 { 1.0 / min_dt } else { 0.0 },
179            avg_fps: if avg_dt > 0.0 { 1.0 / avg_dt } else { 0.0 },
180            frame_time_ms: current_delta * 1000.0,
181        };
182    }
183}
184
185impl Default for DebugOverlay {
186    fn default() -> Self {
187        Self::new(0.5)
188    }
189}
190
191// =============================================================================
192// Tests
193// =============================================================================
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198
199    #[test]
200    fn test_new_overlay_has_default_stats() {
201        let overlay = DebugOverlay::new(0.5);
202        let stats = overlay.stats();
203        assert_eq!(stats.current_fps, 0.0);
204        assert_eq!(stats.avg_fps, 0.0);
205        assert_eq!(stats.min_fps, 0.0);
206        assert_eq!(stats.max_fps, 0.0);
207        assert_eq!(stats.frame_time_ms, 0.0);
208    }
209
210    #[test]
211    fn test_overlay_disabled_by_default() {
212        let overlay = DebugOverlay::new(0.5);
213        assert!(!overlay.is_enabled());
214    }
215
216    #[test]
217    fn test_set_enabled() {
218        let mut overlay = DebugOverlay::new(0.5);
219        overlay.set_enabled(true);
220        assert!(overlay.is_enabled());
221        overlay.set_enabled(false);
222        assert!(!overlay.is_enabled());
223    }
224
225    #[test]
226    fn test_set_corner() {
227        let mut overlay = DebugOverlay::new(0.5);
228        assert_eq!(overlay.corner(), OverlayCorner::TopLeft);
229        overlay.set_corner(OverlayCorner::BottomRight);
230        assert_eq!(overlay.corner(), OverlayCorner::BottomRight);
231    }
232
233    #[test]
234    fn test_stats_computed_after_interval() {
235        let mut overlay = DebugOverlay::new(0.5);
236
237        // Feed frames that do NOT reach the 0.5s interval
238        for _ in 0..10 {
239            overlay.update(0.016); // 10 * 0.016 = 0.16s < 0.5s
240        }
241        // Stats should still be zero (interval not reached)
242        let stats = overlay.stats();
243        assert_eq!(stats.current_fps, 0.0);
244
245        // Now push enough to cross the interval
246        // We need 0.5 - 0.16 = 0.34 more seconds
247        overlay.update(0.34);
248        let stats = overlay.stats();
249        // current_fps should be 1/0.34
250        assert!((stats.current_fps - 1.0 / 0.34).abs() < 0.01);
251        assert!(stats.avg_fps > 0.0);
252    }
253
254    #[test]
255    fn test_stats_with_known_frame_times() {
256        // Use a very short interval so stats recompute immediately
257        let mut overlay = DebugOverlay::new(0.0);
258
259        // Push three known frame times: 10ms, 20ms, 30ms
260        overlay.update(0.010);
261        overlay.update(0.020);
262        overlay.update(0.030);
263
264        let stats = overlay.stats();
265
266        // current_fps = 1/0.030 ~= 33.33
267        assert!((stats.current_fps - 33.333).abs() < 0.1);
268
269        // avg_dt = (0.01+0.02+0.03)/3 = 0.02 => avg_fps = 50
270        assert!((stats.avg_fps - 50.0).abs() < 0.1);
271
272        // min_fps = 1/max_dt = 1/0.03 ~= 33.33
273        assert!((stats.min_fps - 33.333).abs() < 0.1);
274
275        // max_fps = 1/min_dt = 1/0.01 = 100
276        assert!((stats.max_fps - 100.0).abs() < 0.1);
277
278        // frame_time_ms = 0.030 * 1000 = 30
279        assert!((stats.frame_time_ms - 30.0).abs() < 0.1);
280    }
281
282    #[test]
283    fn test_rolling_window_eviction() {
284        let mut overlay = DebugOverlay::new(0.0);
285
286        // Fill beyond capacity (120)
287        for _ in 0..150 {
288            overlay.update(0.016);
289        }
290
291        // Internal window should be capped at 120
292        assert_eq!(overlay.frame_times.len(), DEFAULT_WINDOW_CAPACITY);
293    }
294
295    #[test]
296    fn test_single_frame() {
297        let mut overlay = DebugOverlay::new(0.0);
298        overlay.update(0.016);
299
300        let stats = overlay.stats();
301        assert!((stats.current_fps - 62.5).abs() < 0.1);
302        assert!((stats.avg_fps - 62.5).abs() < 0.1);
303        assert!((stats.frame_time_ms - 16.0).abs() < 0.1);
304    }
305
306    #[test]
307    fn test_zero_delta_time() {
308        let mut overlay = DebugOverlay::new(0.0);
309        overlay.update(0.0);
310
311        let stats = overlay.stats();
312        // Zero delta => 0 fps, 0 frame_time_ms
313        assert_eq!(stats.current_fps, 0.0);
314        assert_eq!(stats.frame_time_ms, 0.0);
315    }
316
317    #[test]
318    fn test_update_interval_respects_timing() {
319        let mut overlay = DebugOverlay::new(1.0);
320
321        // 60 frames at 16ms = 0.96s (under 1.0s interval)
322        for _ in 0..60 {
323            overlay.update(0.016);
324        }
325        assert_eq!(overlay.stats().current_fps, 0.0); // not yet recomputed
326
327        // One more frame pushes over 1.0s
328        overlay.update(0.016); // total ~0.976s... still under
329                               // Need a bit more
330        overlay.update(0.04); // total now > 1.0s
331        assert!(overlay.stats().current_fps > 0.0);
332    }
333
334    #[test]
335    fn test_set_update_interval() {
336        let mut overlay = DebugOverlay::new(1.0);
337        overlay.set_update_interval(0.1);
338
339        // Now a shorter window should trigger recomputation
340        for _ in 0..10 {
341            overlay.update(0.016);
342        }
343        // 10 * 0.016 = 0.16s > 0.1s interval
344        assert!(overlay.stats().current_fps > 0.0);
345    }
346
347    #[test]
348    fn test_default_overlay() {
349        let overlay = DebugOverlay::default();
350        assert!(!overlay.is_enabled());
351        assert_eq!(overlay.corner(), OverlayCorner::TopLeft);
352    }
353
354    #[test]
355    fn test_overlay_corner_default() {
356        assert_eq!(OverlayCorner::default(), OverlayCorner::TopLeft);
357    }
358
359    #[test]
360    fn test_fps_stats_default() {
361        let stats = FpsStats::default();
362        assert_eq!(stats.current_fps, 0.0);
363        assert_eq!(stats.min_fps, 0.0);
364        assert_eq!(stats.max_fps, 0.0);
365        assert_eq!(stats.avg_fps, 0.0);
366        assert_eq!(stats.frame_time_ms, 0.0);
367    }
368}