Skip to main content

concinnity_core/gfx/
profile.rs

1//! Per-frame profiling data. Backend-agnostic: `World::step` records each
2//! system's CPU step time here, the active render backend writes its draw /
3//! GPU stats here, and `StatHud` reads it back to drive the on-screen HUD.
4//! The debug server's `profile` command also reports it for headless
5//! verification.
6
7use alloc::vec::Vec;
8
9/// Maximum number of per-pass GPU timings tracked by [`RenderStats`]. Must be
10/// at least the client render graph's `PassId` count (`PASS_COUNT`), which the
11/// per-pass timing loop iterates; sized with headroom so unused slots carry the
12/// `""` sentinel name and a zero microsecond reading.
13pub const MAX_PASS_TIMINGS: usize = 32;
14
15/// One per-pass GPU timing measurement: a stable pass name and the GPU
16/// microseconds spent in that pass during the most recently completed frame.
17/// Empty-string entries are unused slots, not real passes.
18pub type PassTiming = (&'static str, u32);
19
20/// Per-frame render-backend statistics.
21#[derive(Debug, Clone, Copy, PartialEq)]
22pub struct RenderStats {
23    /// CPU-issued geometry draw calls this frame: the shadow, main, and
24    /// composite + text passes. The optional screen-space-effect passes (SSR,
25    /// SSAO, TAA, bloom) issue a fixed handful of fullscreen draws and are not
26    /// counted here -- `gpu_frame_us` still covers their GPU time.
27    pub draw_calls: u32,
28    /// Renderable objects in the scene this frame: static draw objects, every
29    /// instanced-cluster instance, and skinned meshes.
30    pub objects: u32,
31    /// Visible skinned objects this frame: the authored skinned meshes plus any
32    /// live runtime-spawned instances, excluding the hidden pre-reserved
33    /// instance-pool slots. Unlike `objects` (which counts the whole pre-reserved
34    /// pool and so stays flat across skinned spawn/despawn), this tracks the live
35    /// count, so a spawn bumps it and a despawn drops it.
36    pub skinned_visible: u32,
37    /// Free slots remaining in the pre-reserved skinned instance pool across all
38    /// templates. Drains by one when a skinned instance spawns and refills when
39    /// one despawns, so a probe can watch the free-list recycle directly.
40    pub skinned_pool_free: u32,
41    /// GPU execution time of the most recently completed frame, in
42    /// microseconds. Reported one or more frames late: the GPU timestamps are
43    /// only known once that frame's command buffer completion handler fires.
44    pub gpu_frame_us: u32,
45    /// Bytes of GPU memory currently allocated by the render device. On
46    /// unified-memory hardware (Apple Silicon) this is the device's share of
47    /// system memory rather than dedicated VRAM.
48    pub vram_bytes: u64,
49    /// Bytes the render graph's transient pool holds: the aliased footprint of
50    /// the slots backing the graph-owned transients. Part of `vram_bytes`, which
51    /// is a device-wide total; carried separately so the shared memory ledger can
52    /// attribute it rather than leaving it in the unaccounted remainder.
53    pub transient_pool_bytes: u64,
54    /// Per-pass GPU microseconds for the most recently completed frame.
55    /// Filled by the active backend only when its GPU supports timestamp
56    /// sampling; otherwise every slot stays at the default
57    /// `("", 0)`. Slot order is backend-defined and stable for the process
58    /// lifetime.
59    pub pass_times_us: [PassTiming; MAX_PASS_TIMINGS],
60    /// Current adapted exposure value (EV) from the auto-exposure EMA.
61    /// `None` when the world did not opt in to auto-exposure (or the active
62    /// backend has not yet wired the readout). The `StatHud` overlay reads
63    /// this to render the on-screen EV chip; downstream consumers can map
64    /// it to an exposure multiplier via `2^ev`.
65    pub auto_exposure_ev: Option<f32>,
66    /// Active display's reported maximum extended-range colour-component
67    /// multiplier when the renderer is on the HDR path. `Some(2.0)` on a
68    /// typical HDR400 panel, `Some(8.0+)` on HDR1000-class panels; `None`
69    /// on SDR (because the world disabled HDR, the platform fell back, or
70    /// the active backend has not wired the readout). The `StatHud` overlay
71    /// reads this to render the on-screen `EDR` chip; the value is also
72    /// the linear scaling factor between SDR reference white and the
73    /// panel's peak brightness, so a colour-grading consumer can interpret
74    /// it directly.
75    pub max_edr: Option<f32>,
76}
77
78impl Default for RenderStats {
79    fn default() -> Self {
80        Self {
81            draw_calls: 0,
82            objects: 0,
83            skinned_visible: 0,
84            skinned_pool_free: 0,
85            gpu_frame_us: 0,
86            vram_bytes: 0,
87            transient_pool_bytes: 0,
88            pass_times_us: [("", 0); MAX_PASS_TIMINGS],
89            auto_exposure_ev: None,
90            max_edr: None,
91        }
92    }
93}
94
95/// Timing collected for one frame and read back by the profiler overlay.
96///
97/// The system timings are double-buffered: a frame accumulates into
98/// `current`, and `begin_frame` rotates the just-finished frame into `last`
99/// so a reader always sees a complete frame rather than a partial one.
100#[derive(Debug, Default)]
101pub struct FrameProfile {
102    // System CPU step times from the last fully completed frame, in step
103    // order: `(system name, microseconds)`.
104    last: Vec<(&'static str, u32)>,
105    // Accumulator for the frame currently in progress.
106    current: Vec<(&'static str, u32)>,
107    // Heap allocations counted during each system's step, rotated with the
108    // timings. Sampled by the frame loop in dev builds only; empty otherwise.
109    // The counters are process-wide, so a delta includes what other threads
110    // (streaming workers, the pipelined render half) allocated meanwhile --
111    // attribution is approximate, the frame total is exact churn.
112    last_allocs: Vec<(&'static str, u32)>,
113    current_allocs: Vec<(&'static str, u32)>,
114    // Heap allocations counted across the whole most recent frame. `None` in
115    // release builds and in binaries without the tracking allocator.
116    frame_allocs: Option<u32>,
117    /// Render-backend stats for the most recent drawn frame. Left at the
118    /// default when no graphics backend is running.
119    pub render: RenderStats,
120}
121
122impl FrameProfile {
123    /// Rotate the system-timing buffers at the start of a frame: the frame
124    /// that just finished becomes the readable snapshot and the accumulator
125    /// is cleared for the new frame.
126    pub fn begin_frame(&mut self) {
127        core::mem::swap(&mut self.last, &mut self.current);
128        self.current.clear();
129        core::mem::swap(&mut self.last_allocs, &mut self.current_allocs);
130        self.current_allocs.clear();
131    }
132
133    /// Record one system's CPU step time for the in-progress frame.
134    pub fn record_system(&mut self, name: &'static str, micros: u32) {
135        self.current.push((name, micros));
136    }
137
138    /// Record the heap allocations counted during one system's step.
139    pub fn record_system_allocs(&mut self, name: &'static str, allocs: u32) {
140        self.current_allocs.push((name, allocs));
141    }
142
143    /// Record the heap allocations counted across the frame that just finished.
144    /// Written at the end of a step rather than rotated: the value is complete
145    /// the moment the frame is, so readers between steps see the latest frame.
146    pub fn set_frame_allocs(&mut self, allocs: u32) {
147        self.frame_allocs = Some(allocs);
148    }
149
150    /// System step times from the last fully completed frame, in step order.
151    /// Read by the runtime debug server's `profile` command (a binary-only
152    /// module), so the lib build sees no caller.
153    pub fn system_timings(&self) -> &[(&'static str, u32)] {
154        &self.last
155    }
156
157    /// Per-system heap-allocation counts from the last fully completed frame,
158    /// in step order. Empty unless the frame loop sampled them (dev builds with
159    /// the tracking allocator installed).
160    pub fn system_allocs(&self) -> &[(&'static str, u32)] {
161        &self.last_allocs
162    }
163
164    /// Heap allocations counted across the most recent frame, under the same
165    /// conditions as `system_allocs`.
166    pub fn frame_allocs(&self) -> Option<u32> {
167        self.frame_allocs
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn timings_empty_until_first_rotation() {
177        let mut p = FrameProfile::default();
178        p.record_system("A", 100);
179        // Nothing is readable until begin_frame rotates the accumulator.
180        assert!(p.system_timings().is_empty());
181        p.begin_frame();
182        assert_eq!(p.system_timings(), &[("A", 100)]);
183    }
184
185    #[test]
186    fn begin_frame_rotates_and_clears() {
187        let mut p = FrameProfile::default();
188        p.record_system("A", 10);
189        p.record_system("B", 20);
190        p.begin_frame();
191        assert_eq!(p.system_timings(), &[("A", 10), ("B", 20)]);
192        // The next frame records fresh values; the snapshot only updates on
193        // the following begin_frame.
194        p.record_system("A", 99);
195        assert_eq!(p.system_timings(), &[("A", 10), ("B", 20)]);
196        p.begin_frame();
197        assert_eq!(p.system_timings(), &[("A", 99)]);
198    }
199
200    // Alloc counts rotate with the timings, and stay empty when the frame
201    // loop never sampled them (release builds, untracked binaries).
202    #[test]
203    fn alloc_counts_rotate_with_the_timings() {
204        let mut p = FrameProfile::default();
205        p.record_system("A", 10);
206        p.begin_frame();
207        assert!(p.system_allocs().is_empty(), "unsampled stays empty");
208
209        p.record_system("A", 10);
210        p.record_system_allocs("A", 3);
211        assert!(p.system_allocs().is_empty(), "readable only after rotation");
212        p.begin_frame();
213        assert_eq!(p.system_allocs(), &[("A", 3)]);
214    }
215
216    // The whole-frame count is written when the frame ends, so it reads back
217    // immediately rather than one rotation late.
218    #[test]
219    fn frame_allocs_read_back_without_a_rotation() {
220        let mut p = FrameProfile::default();
221        assert_eq!(p.frame_allocs(), None);
222        p.set_frame_allocs(42);
223        assert_eq!(p.frame_allocs(), Some(42));
224        p.begin_frame();
225        assert_eq!(p.frame_allocs(), Some(42), "rotation leaves it in place");
226    }
227
228    #[test]
229    fn render_stats_default_is_zero() {
230        let p = FrameProfile::default();
231        assert_eq!(p.render, RenderStats::default());
232        assert_eq!(p.render.draw_calls, 0);
233    }
234}