viewport_lib/renderer/stats.rs
1//! Per-frame performance counters for the viewport renderer.
2
3/// Controls the renderer's internal default behavior.
4///
5/// The host application owns playback state, time, and scene content.
6/// `RuntimeMode` tells the renderer what workload to expect so it can adjust
7/// internal defaults (e.g. picking rate) accordingly.
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
9#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
10pub enum RuntimeMode {
11 /// Prioritize responsiveness and picking accuracy.
12 #[default]
13 Interactive,
14 /// Prioritize steady frame pacing; picking may be throttled.
15 Playback,
16 /// Restore full quality; picking runs at full rate.
17 Paused,
18 /// Full-quality render intended for screenshot or export workflows.
19 ///
20 /// Forces `render_scale` to `max_render_scale` for the duration of the frame
21 /// and suppresses all pass-level degradation regardless of `missed_budget`.
22 /// The adaptation controller is paused; render scale resumes from
23 /// `max_render_scale` on the next non-Capture frame.
24 ///
25 /// Note: blocking until GPU work is complete (for pixel readback) is the
26 /// caller's responsibility. This mode ensures full-quality CPU-side decisions
27 /// only; it does not insert any GPU synchronisation inside the renderer.
28 Capture,
29}
30
31/// A coarse quality tier for [`PerformancePolicy`].
32///
33/// When `PerformancePolicy::preset` is `Some`, the renderer derives render scale
34/// bounds and pass-level degradation flags from the preset instead of the individual
35/// policy fields. The individual fields are still persisted so that switching back
36/// to `None` restores the previous custom configuration.
37///
38/// `target_fps` and `allow_dynamic_resolution` are always taken from the policy
39/// fields regardless of the preset.
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
42pub enum QualityPreset {
43 /// Native resolution, all passes enabled, no degradation allowed.
44 ///
45 /// Equivalent to `min_render_scale = 1.0`, `max_render_scale = 1.0`,
46 /// all `allow_*` flags false.
47 High,
48 /// Render scale [0.75, 1.0]; shadow reduction and effect throttling allowed.
49 ///
50 /// Equivalent to `min_render_scale = 0.75`, `max_render_scale = 1.0`,
51 /// `allow_shadow_reduction = true`, `allow_effect_throttling = true`.
52 Medium,
53 /// Render scale [0.5, 0.75]; all degradation paths allowed.
54 ///
55 /// Equivalent to `min_render_scale = 0.5`, `max_render_scale = 0.75`,
56 /// all `allow_*` flags true.
57 Low,
58}
59
60/// Controls what quality reductions the viewport is allowed to apply under load.
61///
62/// Set once via [`crate::ViewportRenderer::set_performance_policy`]. The internal
63/// adaptation controller reads `target_fps` and adjusts render scale within
64/// `[min_render_scale, max_render_scale]` when `allow_dynamic_resolution` is true.
65///
66/// Pass-specific flags (`allow_shadow_reduction`, `allow_volume_quality_reduction`,
67/// `allow_effect_throttling`) gate concrete quality reductions that kick in when
68/// the previous frame missed the target budget, applied in order via a tiered
69/// degradation ladder (render scale first, then shadows, then volumes, then effects).
70///
71/// Set `preset` to a [`QualityPreset`] to configure bounds and flags as a unit.
72#[derive(Debug, Clone, Copy, PartialEq)]
73#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
74pub struct PerformancePolicy {
75 /// Coarse quality tier. When `Some`, overrides `min_render_scale`,
76 /// `max_render_scale`, and all `allow_*` flags for the duration of the frame.
77 /// Set to `None` to use the individual fields directly.
78 pub preset: Option<QualityPreset>,
79 /// Target frames per second. `None` means uncapped; `missed_budget` is always `false`.
80 pub target_fps: Option<f32>,
81 /// Lower bound for dynamic render scale (e.g. 0.5 = half resolution).
82 ///
83 /// Ignored when `preset` is `Some`; the preset's bounds are used instead.
84 pub min_render_scale: f32,
85 /// Upper bound for dynamic render scale (1.0 = native).
86 ///
87 /// Ignored when `preset` is `Some`; the preset's bounds are used instead.
88 pub max_render_scale: f32,
89 /// Allow the viewport to adjust render scale automatically when budget is exceeded.
90 ///
91 /// When `false`, the internal controller is inactive and render scale can be
92 /// set manually via [`crate::ViewportRenderer::set_render_scale`].
93 ///
94 /// The controller uses `FrameStats::gpu_frame_ms` as the cost signal when GPU
95 /// timestamp queries are available (excludes vsync wait). When GPU timestamps are
96 /// unavailable it falls back to `total_frame_ms`, which reflects wall-clock frame
97 /// duration and correctly fires over-budget at low frame rates.
98 ///
99 /// Works in both the LDR path (`paint_to` / `paint_viewport`) and the HDR
100 /// path (`render` / `render_viewport`). In HDR mode, the scene textures
101 /// (HDR colour, depth, bloom, SSAO, DoF, etc.) are allocated at
102 /// `render_scale * resolution`; the tone-map pass upscales to native
103 /// resolution. `FrameStats::render_scale` reflects the actual scale in both
104 /// modes.
105 pub allow_dynamic_resolution: bool,
106 /// Allow the viewport to skip the shadow pass under load.
107 ///
108 /// When `true` and the previous frame exceeded the target budget, the shadow depth
109 /// pass is skipped entirely. Shadows reappear as soon as the frame is within budget.
110 /// Ignored when `preset` is `Some`.
111 pub allow_shadow_reduction: bool,
112 /// Allow the viewport to reduce volume raymarch quality under load.
113 ///
114 /// When `true` and the previous frame exceeded the target budget, the per-volume
115 /// step size is doubled (half the number of samples), reducing GPU cost at the
116 /// cost of coarser volume appearance. Ignored when `preset` is `Some`.
117 pub allow_volume_quality_reduction: bool,
118 /// Allow the viewport to skip non-essential HDR effect passes under load.
119 ///
120 /// When `true` and the previous frame exceeded the target budget, the SSAO,
121 /// contact shadow, and bloom passes are skipped for that frame.
122 /// Ignored when `preset` is `Some`.
123 pub allow_effect_throttling: bool,
124}
125
126impl Default for PerformancePolicy {
127 fn default() -> Self {
128 Self {
129 preset: None,
130 target_fps: None,
131 min_render_scale: 0.5,
132 max_render_scale: 1.0,
133 allow_dynamic_resolution: false,
134 allow_shadow_reduction: false,
135 allow_volume_quality_reduction: false,
136 allow_effect_throttling: false,
137 }
138 }
139}
140
141/// CPU time spent in each phase of `prepare()`, in milliseconds.
142///
143/// `FrameStats::cpu_prepare_ms` is the total. This splits that total across the
144/// main phases so a slow `prepare` can be attributed to a specific one instead
145/// of guessing. The fields sum to roughly `cpu_prepare_ms`; `other_ms` carries
146/// the remainder (timestamp readback, scatter-volume sort, stats assembly, and
147/// other small per-frame work).
148///
149/// All values are wall-clock CPU time on the thread calling `prepare`, measured
150/// with `Instant`. They measure how long it takes to record and submit the work,
151/// not how long the GPU spends running it; use `gpu_frame_ms` for GPU cost.
152#[derive(Debug, Clone, Copy, Default)]
153pub struct PrepareBreakdown {
154 /// Item-type plugin prepare and cull dispatch. Skinning and other vertex
155 /// deformers run here, so a heavy skinned crowd shows up in this field.
156 pub plugin_ms: f32,
157 /// Lighting setup: directional shadow cascade matrices, point-light cube-map
158 /// faces, and light clustering.
159 pub lighting_ms: f32,
160 /// Per-object uniform writes on the non-instanced path. Zero when the scene
161 /// is above the instancing threshold and all meshes batch.
162 pub uniforms_ms: f32,
163 /// Building and uploading instanced batches (grouping items by mesh and
164 /// material, writing instance data).
165 pub instancing_ms: f32,
166 /// Uploading non-mesh geometry: glyphs, point clouds, polylines, decals,
167 /// images, tubes, ribbons, slices, and volumes.
168 pub geometry_ms: f32,
169 /// Recording the shadow depth pass (directional cascades and point-light
170 /// cube-map faces) into the atlas.
171 pub shadow_ms: f32,
172 /// Per-viewport prepare: camera and clip uniforms, grid, overlays, and
173 /// interaction state. Runs once per viewport.
174 pub viewport_ms: f32,
175 /// Remainder of `prepare` not covered by the fields above.
176 pub other_ms: f32,
177}
178
179/// Per-pass split of GPU frame time, measured with `TIMESTAMP_QUERY`.
180///
181/// Each field is the GPU duration of one render pass in milliseconds, captured
182/// with timestamp queries around the pass. A field is `0.0` when that pass did
183/// not run this frame (for example `shadow_ms` when shadows are off or
184/// `oit_ms` when nothing transparent is drawn) or when the backend does not
185/// support `TIMESTAMP_QUERY` (the same condition that leaves
186/// [`FrameStats::gpu_frame_ms`] at `None`).
187///
188/// These passes are submitted in separate command buffers but resolved
189/// together, so the values are comparable. Like `gpu_frame_ms`, they lag one
190/// frame behind due to async readback. The passes measured here do not cover
191/// every GPU pass (decals, scatter, bloom, depth of field, and the final
192/// overlays are not split out), so the fields do not sum to the full frame GPU
193/// time; treat the remainder as those un-instrumented passes plus present.
194#[derive(Debug, Clone, Copy, Default)]
195pub struct GpuBreakdown {
196 /// Main opaque HDR scene pass. This is the same span as
197 /// [`FrameStats::gpu_frame_ms`].
198 pub scene_ms: f32,
199 /// Directional shadow depth pass (all cascades rendered into the atlas).
200 pub shadow_ms: f32,
201 /// Order-independent transparency accumulation pass.
202 pub oit_ms: f32,
203 /// Tone-map and resolve pass that writes the final LDR image.
204 pub post_ms: f32,
205 /// Main-camera GPU cull dispatch: the `cull_instances` + `write_indirect_args`
206 /// compute passes that produce the indirect draw args. `0.0` when GPU culling
207 /// is off or the scene has no instanced batches. Shadow-cascade culls are not
208 /// included here. Useful for checking whether the cull pass costs more than it
209 /// saves on a given scene.
210 pub cull_ms: f32,
211}
212
213/// Per-frame rendering statistics returned by [`crate::ViewportRenderer::prepare`].
214#[derive(Debug, Clone, Copy, Default)]
215pub struct FrameStats {
216 /// Total objects considered for rendering.
217 pub total_objects: u32,
218 /// Objects that passed visibility and frustum tests.
219 pub visible_objects: u32,
220 /// Objects culled by frustum or visibility.
221 pub culled_objects: u32,
222 /// Number of draw calls issued in the main pass.
223 pub draw_calls: u32,
224 /// Number of instanced batches (0 when using per-object path).
225 pub instanced_batches: u32,
226 /// Visible items that miss the instanced fast path and are drawn one at a
227 /// time through the per-object path.
228 ///
229 /// An item is non-instanceable when it uses a styled back-face policy
230 /// (different-colour/tint/pattern) or is two-sided and transparent, uses a
231 /// matcap, has a scalar attribute or parameter visualization, carries a
232 /// position/normal override, has per-instance deform data (skinning), or is
233 /// hit by a compute filter. Each such item costs a uniform write and a bind-group
234 /// build in `prepare`, so a large count here means `prepare` is paying
235 /// per-object cost across much of the scene rather than batching it.
236 pub per_object_items: u32,
237 /// Per-object bind groups actually (re)built this frame.
238 ///
239 /// The per-object path caches bind groups and skips the rebuild when the
240 /// item's textures, attributes, and overrides are unchanged. A value near
241 /// `per_object_items` means the cache is missing most frames (for example
242 /// because items move between slots); a value near zero means the remaining
243 /// per-object cost is the uniform writes, not the bind-group builds.
244 pub per_object_bind_groups_built: u32,
245 /// Instanced batches whose GPU sub-range was re-uploaded this frame.
246 ///
247 /// Non-zero only on cache-miss frames where at least one batch's instance
248 /// data changed. Zero on cache-hit frames and when the per-object path is
249 /// active. Together with `batches_skipped`, this lets callers measure how
250 /// effective the dirty-flagging optimisation is for their scene.
251 pub batches_reuploaded: u32,
252 /// Instanced batches whose GPU sub-range was reused unchanged this frame.
253 ///
254 /// Non-zero only on cache-miss frames where at least one batch compared
255 /// equal to the cached data and its write was skipped. Zero on cache-hit
256 /// frames (no upload attempted at all) and when the per-object path is
257 /// active.
258 pub batches_skipped: u32,
259 /// Decals whose GPU buffer + bind group were built this frame (cache miss).
260 ///
261 /// Non-zero only when a decal is new or its content changed. In steady state
262 /// with static decals this is zero after the first frame: a persistent value
263 /// near the decal count means the cache is missing every frame.
264 pub decal_uploads: u32,
265 /// Decals served from the cross-frame cache this frame (cache hit).
266 ///
267 /// Together with `decal_uploads` this shows how effective the decal resource
268 /// cache is: `decal_reused` should equal the visible decal count and
269 /// `decal_uploads` should be zero once decals are settled.
270 pub decal_reused: u32,
271 /// Total triangles submitted to the GPU.
272 pub triangles_submitted: u64,
273 /// Number of draw calls in the shadow pass.
274 pub shadow_draw_calls: u32,
275 /// CPU time spent in `prepare()`, in milliseconds.
276 pub cpu_prepare_ms: f32,
277 /// Per-phase split of `cpu_prepare_ms`. See [`PrepareBreakdown`].
278 pub prepare_breakdown: PrepareBreakdown,
279 /// CPU time spent encoding the paint pass (draw-call recording) in
280 /// milliseconds, separate from `cpu_prepare_ms`.
281 ///
282 /// Measured around the render/paint encode and latched after it, so
283 /// `last_frame_stats()` reflects the current frame once `render` has run.
284 /// The value carried in the `FrameStats` returned by `prepare()` is the
285 /// previous frame's paint, since paint runs after prepare. For
286 /// multi-viewport rendering this reflects the most recently painted
287 /// viewport, not the sum.
288 pub cpu_paint_ms: f32,
289 /// GPU scene-pass time in milliseconds, if timestamp queries are available.
290 ///
291 /// Measured with `TIMESTAMP_QUERY` around the main scene render pass.
292 /// `None` on backends that do not support `TIMESTAMP_QUERY` (e.g. WebGL).
293 ///
294 /// Note: this value reflects the *previous* frame's GPU cost due to async
295 /// readback. The value lags by one frame and should not be used by the
296 /// adaptation controller across mode transitions.
297 pub gpu_frame_ms: Option<f32>,
298 /// Per-pass split of GPU frame time. See [`GpuBreakdown`].
299 ///
300 /// Populated under the same conditions as `gpu_frame_ms` (requires
301 /// `TIMESTAMP_QUERY`); all fields are `0.0` otherwise. `scene_ms` matches
302 /// `gpu_frame_ms`.
303 pub gpu_breakdown: GpuBreakdown,
304 /// Wall-clock duration since the previous `prepare()` call, in milliseconds.
305 ///
306 /// Approximates the full frame interval. Zero on the first frame.
307 pub total_frame_ms: f32,
308 /// Current internal render scale (1.0 = native resolution).
309 ///
310 /// Reflects the value tracked by the adaptation controller. Values below 1.0
311 /// cause the scene to render into a scaled intermediate texture that is
312 /// bilinearly upscaled to the surface (requires `allow_dynamic_resolution`).
313 pub render_scale: f32,
314 /// True if the last frame exceeded the target frame budget.
315 ///
316 /// Requires `target_fps` to be set in the [`PerformancePolicy`]. Always
317 /// `false` when no target is configured.
318 pub missed_budget: bool,
319 /// Bytes of geometry data uploaded to the GPU since the previous
320 /// `prepare()` call.
321 ///
322 /// Counts full buffer reallocations triggered by
323 /// [`crate::DeviceResources::replace_mesh_data`] and initial uploads
324 /// via `upload_mesh_data` / `upload_mesh`. Uniform buffer writes are not
325 /// counted.
326 pub upload_bytes: u64,
327 /// True when GPU-driven culling is active this frame.
328 ///
329 /// False when the device does not support `INDIRECT_FIRST_INSTANCE` or
330 /// culling has been disabled via `disable_gpu_driven_culling()`.
331 pub gpu_culling_active: bool,
332 /// Number of instances that passed GPU culling and were submitted for drawing.
333 ///
334 /// This is the count after both the frustum and occlusion tests. Populated
335 /// only when `gpu_culling_active` is true. `None` on the first frame or when
336 /// GPU culling is off. Lags by one frame due to async readback.
337 pub gpu_visible_instances: Option<u32>,
338 /// Total instances considered by the main-camera cull this frame, before
339 /// any test. Same readback and `None` conditions as `gpu_visible_instances`.
340 pub gpu_culled_total: Option<u32>,
341 /// Instances that survived the frustum test, before the HiZ occlusion test.
342 ///
343 /// `gpu_culled_total - gpu_frustum_visible` is the frustum-culled count, and
344 /// `gpu_frustum_visible - gpu_visible_instances` is the occlusion-culled
345 /// count. When occlusion culling is off, this equals `gpu_visible_instances`.
346 /// Same readback and `None` conditions as `gpu_visible_instances`.
347 pub gpu_frustum_visible: Option<u32>,
348
349 // --- Per-pass degradation flags ---
350 /// True when the shadow depth pass was skipped this frame.
351 ///
352 /// Set when `allow_shadow_reduction` is true and the previous frame missed
353 /// the target budget. Always false in [`RuntimeMode::Capture`].
354 pub shadows_skipped: bool,
355 /// True when volume raymarch step size was doubled this frame.
356 ///
357 /// Set when `allow_volume_quality_reduction` is true and the previous frame
358 /// missed the target budget. Always false in [`RuntimeMode::Capture`].
359 pub volume_quality_reduced: bool,
360 /// True when SSAO, contact shadows, and bloom were skipped this frame.
361 ///
362 /// Set when `allow_effect_throttling` is true and the previous frame missed
363 /// the target budget. Always false in [`RuntimeMode::Capture`].
364 pub effects_throttled: bool,
365
366 /// Objects that resolved through a LOD group this frame.
367 ///
368 /// Counts `SceneRenderItem`s with a `lod_group` set, plus each individual
369 /// `MeshInstanceItem` instance whose level was selected. Zero when nothing
370 /// uses LOD.
371 pub lod_items_resolved: u32,
372 /// LOD objects whose chosen level changed from the previous frame.
373 ///
374 /// Counts both scene items and individual mesh instances that moved to a
375 /// different level. Only objects with a `pick_id` are tracked across frames,
376 /// so this counts switches among those. A high value relative to
377 /// `lod_items_resolved` suggests thresholds are being crossed often (camera
378 /// motion, or thresholds spaced too tightly for the hysteresis margin).
379 pub lod_switches: u32,
380 /// Objects dropped this frame because they fell below their LOD group's cull
381 /// size. Counts hidden `SceneRenderItem`s plus individual mesh instances that
382 /// were left out of every batch. Zero unless a group sets `cull_below`.
383 pub lod_culled: u32,
384 /// LOD objects drawn this frame at a reduced level (level index > 0, i.e.
385 /// below full detail). Counts scene items plus individual mesh instances.
386 ///
387 /// Unlike [`lod_switches`](Self::lod_switches), this does not need per-object
388 /// tracking, so it is accurate for objects with no `pick_id`. It is the
389 /// reliable signal for "is LOD actually reducing geometry": if it stays near
390 /// zero while distant objects fill the view, either the heavy meshes are not
391 /// in LOD groups or the `min_screen_size` thresholds never trigger, and the
392 /// triangle count is not coming down.
393 pub lod_items_reduced: u32,
394}
395
396#[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
401 fn test_frame_stats_default_is_zero() {
402 let stats = FrameStats::default();
403 assert_eq!(stats.total_objects, 0);
404 assert_eq!(stats.visible_objects, 0);
405 assert_eq!(stats.culled_objects, 0);
406 assert_eq!(stats.draw_calls, 0);
407 assert_eq!(stats.instanced_batches, 0);
408 assert_eq!(stats.batches_reuploaded, 0);
409 assert_eq!(stats.batches_skipped, 0);
410 assert_eq!(stats.triangles_submitted, 0);
411 assert_eq!(stats.shadow_draw_calls, 0);
412 assert_eq!(stats.cpu_prepare_ms, 0.0);
413 assert!(stats.gpu_frame_ms.is_none());
414 assert_eq!(stats.total_frame_ms, 0.0);
415 assert_eq!(stats.render_scale, 0.0);
416 assert!(!stats.missed_budget);
417 assert_eq!(stats.upload_bytes, 0);
418 assert!(!stats.gpu_culling_active);
419 assert!(stats.gpu_visible_instances.is_none());
420 assert!(!stats.shadows_skipped);
421 assert!(!stats.volume_quality_reduced);
422 assert!(!stats.effects_throttled);
423 }
424
425 #[test]
426 fn test_runtime_mode_default_is_interactive() {
427 assert_eq!(RuntimeMode::default(), RuntimeMode::Interactive);
428 }
429
430 #[test]
431 fn test_performance_policy_default() {
432 let p = PerformancePolicy::default();
433 assert!(p.preset.is_none());
434 assert!(p.target_fps.is_none());
435 assert!(!p.allow_dynamic_resolution);
436 assert!(p.min_render_scale <= p.max_render_scale);
437 assert_eq!(p.max_render_scale, 1.0);
438 }
439}