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