Skip to main content

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    /// **LDR path only.** Dynamic resolution applies when the scene is rendered
100    /// via [`crate::ViewportRenderer::paint_to`] or
101    /// [`crate::ViewportRenderer::paint_viewport`] (i.e.
102    /// `PostProcessSettings::enabled` is `false`). When the HDR post-processing
103    /// path is active (`render` / `render_viewport`), render scale has no effect
104    /// on output quality and the adaptation controller is suppressed.
105    /// `FrameStats::render_scale` will report 1.0 in that case.
106    pub allow_dynamic_resolution: bool,
107    /// Allow the viewport to skip the shadow pass under load.
108    ///
109    /// When `true` and the previous frame exceeded the target budget, the shadow depth
110    /// pass is skipped entirely. Shadows reappear as soon as the frame is within budget.
111    /// Ignored when `preset` is `Some`.
112    pub allow_shadow_reduction: bool,
113    /// Allow the viewport to reduce volume raymarch quality under load.
114    ///
115    /// When `true` and the previous frame exceeded the target budget, the per-volume
116    /// step size is doubled (half the number of samples), reducing GPU cost at the
117    /// cost of coarser volume appearance. Ignored when `preset` is `Some`.
118    pub allow_volume_quality_reduction: bool,
119    /// Allow the viewport to skip non-essential HDR effect passes under load.
120    ///
121    /// When `true` and the previous frame exceeded the target budget, the SSAO,
122    /// contact shadow, and bloom passes are skipped for that frame.
123    /// Ignored when `preset` is `Some`.
124    pub allow_effect_throttling: bool,
125}
126
127impl Default for PerformancePolicy {
128    fn default() -> Self {
129        Self {
130            preset: None,
131            target_fps: None,
132            min_render_scale: 0.5,
133            max_render_scale: 1.0,
134            allow_dynamic_resolution: false,
135            allow_shadow_reduction: false,
136            allow_volume_quality_reduction: false,
137            allow_effect_throttling: false,
138        }
139    }
140}
141
142/// Per-frame rendering statistics returned by [`crate::ViewportRenderer::prepare`].
143#[derive(Debug, Clone, Copy, Default)]
144pub struct FrameStats {
145    /// Total objects considered for rendering.
146    pub total_objects: u32,
147    /// Objects that passed visibility and frustum tests.
148    pub visible_objects: u32,
149    /// Objects culled by frustum or visibility.
150    pub culled_objects: u32,
151    /// Number of draw calls issued in the main pass.
152    pub draw_calls: u32,
153    /// Number of instanced batches (0 when using per-object path).
154    pub instanced_batches: u32,
155    /// Total triangles submitted to the GPU.
156    pub triangles_submitted: u64,
157    /// Number of draw calls in the shadow pass.
158    pub shadow_draw_calls: u32,
159    /// CPU time spent in `prepare()`, in milliseconds.
160    pub cpu_prepare_ms: f32,
161    /// GPU scene-pass time in milliseconds, if timestamp queries are available.
162    ///
163    /// Measured with `TIMESTAMP_QUERY` around the main scene render pass.
164    /// `None` on backends that do not support `TIMESTAMP_QUERY` (e.g. WebGL).
165    ///
166    /// Note: this value reflects the *previous* frame's GPU cost due to async
167    /// readback. The value lags by one frame and should not be used by the
168    /// adaptation controller across mode transitions.
169    pub gpu_frame_ms: Option<f32>,
170    /// Wall-clock duration since the previous `prepare()` call, in milliseconds.
171    ///
172    /// Approximates the full frame interval. Zero on the first frame.
173    pub total_frame_ms: f32,
174    /// Current internal render scale (1.0 = native resolution).
175    ///
176    /// Reflects the value tracked by the adaptation controller. Values below 1.0
177    /// cause the scene to render into a scaled intermediate texture that is
178    /// bilinearly upscaled to the surface (requires `allow_dynamic_resolution`).
179    pub render_scale: f32,
180    /// True if the last frame exceeded the target frame budget.
181    ///
182    /// Requires `target_fps` to be set in the [`PerformancePolicy`]. Always
183    /// `false` when no target is configured.
184    pub missed_budget: bool,
185    /// Bytes of geometry data uploaded to the GPU since the previous
186    /// `prepare()` call.
187    ///
188    /// Counts full buffer reallocations triggered by
189    /// [`crate::ViewportGpuResources::replace_mesh_data`] and initial uploads
190    /// via `upload_mesh_data` / `upload_mesh`. Uniform buffer writes are not
191    /// counted.
192    pub upload_bytes: u64,
193    /// True when GPU-driven culling is active this frame.
194    ///
195    /// False when the device does not support `INDIRECT_FIRST_INSTANCE` or
196    /// culling has been disabled via `disable_gpu_driven_culling()`.
197    pub gpu_culling_active: bool,
198    /// Number of instances that passed GPU culling and were submitted for drawing.
199    ///
200    /// Populated only when `gpu_culling_active` is true. `None` on the first frame
201    /// or when GPU culling is off. Lags by one frame due to async readback.
202    pub gpu_visible_instances: Option<u32>,
203
204    // --- Per-pass degradation flags ---
205    /// True when the shadow depth pass was skipped this frame.
206    ///
207    /// Set when `allow_shadow_reduction` is true and the previous frame missed
208    /// the target budget. Always false in [`RuntimeMode::Capture`].
209    pub shadows_skipped: bool,
210    /// True when volume raymarch step size was doubled this frame.
211    ///
212    /// Set when `allow_volume_quality_reduction` is true and the previous frame
213    /// missed the target budget. Always false in [`RuntimeMode::Capture`].
214    pub volume_quality_reduced: bool,
215    /// True when SSAO, contact shadows, and bloom were skipped this frame.
216    ///
217    /// Set when `allow_effect_throttling` is true and the previous frame missed
218    /// the target budget. Always false in [`RuntimeMode::Capture`].
219    pub effects_throttled: bool,
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn test_frame_stats_default_is_zero() {
228        let stats = FrameStats::default();
229        assert_eq!(stats.total_objects, 0);
230        assert_eq!(stats.visible_objects, 0);
231        assert_eq!(stats.culled_objects, 0);
232        assert_eq!(stats.draw_calls, 0);
233        assert_eq!(stats.instanced_batches, 0);
234        assert_eq!(stats.triangles_submitted, 0);
235        assert_eq!(stats.shadow_draw_calls, 0);
236        assert_eq!(stats.cpu_prepare_ms, 0.0);
237        assert!(stats.gpu_frame_ms.is_none());
238        assert_eq!(stats.total_frame_ms, 0.0);
239        assert_eq!(stats.render_scale, 0.0);
240        assert!(!stats.missed_budget);
241        assert_eq!(stats.upload_bytes, 0);
242        assert!(!stats.gpu_culling_active);
243        assert!(stats.gpu_visible_instances.is_none());
244        assert!(!stats.shadows_skipped);
245        assert!(!stats.volume_quality_reduced);
246        assert!(!stats.effects_throttled);
247    }
248
249    #[test]
250    fn test_runtime_mode_default_is_interactive() {
251        assert_eq!(RuntimeMode::default(), RuntimeMode::Interactive);
252    }
253
254    #[test]
255    fn test_performance_policy_default() {
256        let p = PerformancePolicy::default();
257        assert!(p.preset.is_none());
258        assert!(p.target_fps.is_none());
259        assert!(!p.allow_dynamic_resolution);
260        assert!(p.min_render_scale <= p.max_render_scale);
261        assert_eq!(p.max_render_scale, 1.0);
262    }
263}