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}