Skip to main content

viewport_lib/renderer/
mod.rs

1//! `ViewportRenderer` : the main entry point for the viewport library.
2//!
3//! Wraps [`ViewportGpuResources`] and provides `prepare()` / `paint()` methods
4//! that take raw `wgpu` types. GUI framework adapters (e.g. the egui
5//! `CallbackTrait` impl in the application crate) delegate to these methods.
6
7#[macro_use]
8mod types;
9mod indirect;
10mod picking;
11pub use picking::PickRectResult;
12mod prepare;
13mod render;
14pub mod shader_hashes;
15mod shadows;
16pub mod stats;
17
18pub use self::types::{
19    CameraFrame, CameraFrustumItem, ClipObject, ClipShape, ComputeFilterItem, ComputeFilterKind,
20    EffectsFrame, EnvironmentMap, FilterMode, FrameData, GlyphItem, GlyphType, GroundPlane,
21    GroundPlaneMode, ImageAnchor, InteractionFrame, LabelAnchor, LabelItem, LightKind, LightSource,
22    LightingSettings, LoadingBarAnchor, LoadingBarItem, OverlayFrame, OverlayImageItem, PickId,
23    PointCloudItem, PointRenderMode,
24    aabb_wireframe_polyline, PolylineItem, PostProcessSettings, RenderCamera, RulerItem, ScalarBarAnchor, ScalarBarItem,
25    ScalarBarOrientation, SceneEffects,
26    RibbonItem, SceneFrame, SceneRenderItem, ScreenImageItem, VolumeMeshItem,
27    ShadowFilter, SliceAxis, SpriteItem, SpriteSizeMode, StreamtubeItem, SurfaceLICConfig,
28    SurfaceLICItem, SurfaceSubmission,
29    GaussianSplatData, GaussianSplatId, GaussianSplatItem, ShDegree,
30    ImageSliceItem, TensorGlyphItem, ToneMapping, TubeItem,
31    TransparentVolumeMeshItem, VolumeSurfaceSliceItem,
32    ViewportEffects, ViewportFrame, VolumeItem,
33};
34
35/// An opaque handle to a per-viewport GPU state slot.
36///
37/// Obtained from [`ViewportRenderer::create_viewport`] and passed to
38/// [`ViewportRenderer::prepare_viewport`], [`ViewportRenderer::paint_viewport`],
39/// and [`ViewportRenderer::render_viewport`].
40///
41/// The inner `usize` is the slot index and doubles as the value for
42/// [`CameraFrame::with_viewport_index`].  Single-viewport applications that use
43/// the legacy [`ViewportRenderer::prepare`] / [`ViewportRenderer::paint`] API do
44/// not need this type.
45#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
46pub struct ViewportId(pub usize);
47
48use self::shadows::{compute_cascade_matrix, compute_cascade_splits};
49use self::types::{INSTANCING_THRESHOLD, InstancedBatch};
50use crate::resources::{
51    BatchMeta, CameraUniform, ClipPlanesUniform, ClipVolumeEntry, ClipVolumesUniform,
52    CLIP_VOLUME_MAX, GridUniform, InstanceAabb,
53    InstanceData, LightsUniform, ObjectUniform, OutlineEdgeUniform, OutlineObjectBuffers,
54    OutlineUniform, PickInstance, ShadowAtlasUniform, SingleLightUniform,
55    SplatOutlineMaskUniform, ViewportGpuResources,
56};
57
58/// Per-viewport GPU state: uniform buffers and bind groups that differ per viewport.
59///
60/// Each viewport slot owns its own camera, clip planes, clip volume, shadow info,
61/// and grid buffers, plus the bind groups that reference them. Scene-global
62/// resources (lights, shadow atlas texture, IBL) are shared via the bind group
63/// pointing to buffers on `ViewportGpuResources`.
64pub(crate) struct ViewportSlot {
65    pub camera_buf: wgpu::Buffer,
66    pub clip_planes_buf: wgpu::Buffer,
67    pub clip_volume_buf: wgpu::Buffer,
68    pub shadow_info_buf: wgpu::Buffer,
69    pub grid_buf: wgpu::Buffer,
70    /// Camera bind group (group 0) referencing this slot's per-viewport buffers
71    /// plus shared scene-global resources.
72    pub camera_bind_group: wgpu::BindGroup,
73    /// Grid bind group (group 0 for grid pipeline) referencing this slot's grid buffer.
74    pub grid_bind_group: wgpu::BindGroup,
75    /// Per-viewport HDR post-process render targets.
76    ///
77    /// Created lazily on first HDR render call and resized when viewport dimensions change.
78    pub hdr: Option<crate::resources::ViewportHdrState>,
79
80    // --- Per-viewport interaction state (Phase 4) ---
81    /// Per-frame outline buffers for selected objects, rebuilt in prepare().
82    pub outline_object_buffers: Vec<OutlineObjectBuffers>,
83    /// Per-frame outline buffers for selected Gaussian splat sets, rebuilt in prepare().
84    pub splat_outline_buffers: Vec<crate::resources::SplatOutlineBuffers>,
85    /// Indices into `volume_gpu_data` for selected volumes, rebuilt in prepare().
86    pub volume_outline_indices: Vec<usize>,
87    /// Indices into `glyph_gpu_data` for selected glyph sets, rebuilt in prepare().
88    /// Each entry is (gpu_data_index, instance_filter): None draws all instances,
89    /// Some(indices) draws only those specific instance indices.
90    pub glyph_outline_indices: Vec<(usize, Option<Vec<u32>>)>,
91    /// Indices into `tensor_glyph_gpu_data` for selected tensor glyph sets, rebuilt in prepare().
92    pub tensor_glyph_outline_indices: Vec<(usize, Option<Vec<u32>>)>,
93    /// Indices into `sprite_gpu_data` for selected sprite sets, rebuilt in prepare().
94    pub sprite_outline_indices: Vec<(usize, Option<Vec<u32>>)>,
95    /// Per-frame x-ray buffers for selected objects, rebuilt in prepare().
96    pub xray_object_buffers: Vec<(crate::resources::mesh_store::MeshId, wgpu::Buffer, wgpu::BindGroup)>,
97    /// Per-frame constraint guide line buffers, rebuilt in prepare().
98    pub constraint_line_buffers: Vec<(
99        wgpu::Buffer,
100        wgpu::Buffer,
101        u32,
102        wgpu::Buffer,
103        wgpu::BindGroup,
104    )>,
105    /// Per-frame cap geometry buffers (section view cross-section fill), rebuilt in prepare().
106    pub cap_buffers: Vec<(
107        wgpu::Buffer,
108        wgpu::Buffer,
109        u32,
110        wgpu::Buffer,
111        wgpu::BindGroup,
112    )>,
113    /// Per-frame clip plane fill overlay buffers, rebuilt in prepare().
114    pub clip_plane_fill_buffers: Vec<(
115        wgpu::Buffer,
116        wgpu::Buffer,
117        u32,
118        wgpu::Buffer,
119        wgpu::BindGroup,
120    )>,
121    /// Per-frame clip plane line overlay buffers, rebuilt in prepare().
122    pub clip_plane_line_buffers: Vec<(
123        wgpu::Buffer,
124        wgpu::Buffer,
125        u32,
126        wgpu::Buffer,
127        wgpu::BindGroup,
128    )>,
129    /// Vertex buffer for axes indicator geometry (rebuilt each frame).
130    pub axes_vertex_buffer: wgpu::Buffer,
131    /// Number of vertices in the axes indicator buffer.
132    pub axes_vertex_count: u32,
133    /// Gizmo model-matrix uniform buffer.
134    pub gizmo_uniform_buf: wgpu::Buffer,
135    /// Gizmo bind group (group 1: model matrix uniform).
136    pub gizmo_bind_group: wgpu::BindGroup,
137    /// Gizmo vertex buffer.
138    pub gizmo_vertex_buffer: wgpu::Buffer,
139    /// Gizmo index buffer.
140    pub gizmo_index_buffer: wgpu::Buffer,
141    /// Number of indices in the current gizmo mesh.
142    pub gizmo_index_count: u32,
143
144    // --- Sub-object highlight (per-viewport, generation-cached) ---
145    /// Per-viewport dynamic resolution intermediate render target.
146    /// `None` when render_scale == 1.0 or not yet initialised.
147    pub dyn_res: Option<crate::resources::dyn_res::DynResTarget>,
148    /// Per-viewport intermediate render target for the HDR eframe callback path.
149    /// `None` until the first `prepare_hdr_callback` call for this viewport.
150    pub hdr_callback: Option<crate::resources::dyn_res::HdrCallbackTarget>,
151    /// Cached GPU data for sub-object highlight rendering.
152    /// `None` when no sub-object selection is active and no volumes are selected.
153    pub sub_highlight: Option<crate::resources::SubHighlightGpuData>,
154    /// Version of the last sub-selection snapshot that was uploaded.
155    /// `u64::MAX` forces a rebuild on the first frame.
156    pub sub_highlight_generation: u64,
157}
158
159/// Renderer wrapping all GPU resources and providing `prepare()` and `paint()` methods.
160pub struct ViewportRenderer {
161    resources: ViewportGpuResources,
162    /// Instanced batches prepared for the current frame. Empty when using per-object path.
163    instanced_batches: Vec<InstancedBatch>,
164    /// Whether the current frame uses the instanced draw path.
165    use_instancing: bool,
166    /// True when the device supports `INDIRECT_FIRST_INSTANCE`.
167    gpu_culling_supported: bool,
168    /// True when GPU-driven culling is active (supported and not disabled by the caller).
169    gpu_culling_enabled: bool,
170    /// GPU culling compute pipelines and frustum buffer. Created lazily on the first
171    /// frame where `gpu_culling_enabled` is true and instance buffers are present.
172    cull_resources: Option<indirect::CullResources>,
173    /// Performance counters from the last frame.
174    last_stats: crate::renderer::stats::FrameStats,
175    /// Last scene generation seen during prepare(). u64::MAX forces rebuild on first frame.
176    last_scene_generation: u64,
177    /// Last selection generation seen during prepare(). u64::MAX forces rebuild on first frame.
178    last_selection_generation: u64,
179    /// Last scene_items count seen during prepare(). usize::MAX forces rebuild on first frame.
180    /// Included in cache key so that frustum-culling changes (different visible set, different
181    /// count) correctly invalidate the instance buffer even when scene_generation is stable.
182    last_scene_items_count: usize,
183    /// Count of items that passed the instanced-path filter on the last rebuild.
184    /// Used in place of has_per_frame_mutations so scenes that mix instanced and
185    /// non-instanced items (e.g. one two-sided mesh + 10k static boxes) still hit
186    /// the instanced batch cache on frames where the filtered set is unchanged.
187    last_instancable_count: usize,
188    /// Cached instance data from last rebuild (mirrors the GPU buffer contents).
189    cached_instance_data: Vec<InstanceData>,
190    /// Cached instanced batch descriptors from last rebuild.
191    cached_instanced_batches: Vec<InstancedBatch>,
192    /// Per-frame point cloud GPU data, rebuilt in prepare(), consumed in paint().
193    point_cloud_gpu_data: Vec<crate::resources::PointCloudGpuData>,
194    /// Per-frame glyph GPU data, rebuilt in prepare(), consumed in paint().
195    glyph_gpu_data: Vec<crate::resources::GlyphGpuData>,
196    /// Per-frame tensor glyph GPU data, rebuilt in prepare(), consumed in paint() (Phase 5).
197    tensor_glyph_gpu_data: Vec<crate::resources::TensorGlyphGpuData>,
198    /// Per-frame polyline GPU data, rebuilt in prepare(), consumed in paint().
199    polyline_gpu_data: Vec<crate::resources::PolylineGpuData>,
200    /// Per-frame volume GPU data, rebuilt in prepare(), consumed in paint().
201    volume_gpu_data: Vec<crate::resources::VolumeGpuData>,
202    /// Per-frame streamtube GPU data, rebuilt in prepare(), consumed in paint().
203    streamtube_gpu_data: Vec<crate::resources::StreamtubeGpuData>,
204    /// Per-frame general tube GPU data, rebuilt in prepare(), consumed in paint() (Phase 3).
205    tube_gpu_data: Vec<crate::resources::StreamtubeGpuData>,
206    /// Per-frame ribbon GPU data, rebuilt in prepare(), consumed in paint() (Phase 8.1).
207    ribbon_gpu_data: Vec<crate::resources::StreamtubeGpuData>,
208    /// Per-frame image slice GPU data, rebuilt in prepare(), consumed in paint() (Phase 3).
209    image_slice_gpu_data: Vec<crate::resources::ImageSliceGpuData>,
210    /// Per-frame volume surface slice GPU data, rebuilt in prepare(), consumed in paint() (Phase 10).
211    volume_surface_slice_gpu_data: Vec<crate::resources::VolumeSurfaceSliceGpuData>,
212    /// Per-frame Surface LIC GPU data, rebuilt in prepare(), consumed in paint() (Phase 4).
213    lic_gpu_data: Vec<crate::resources::LicSurfaceGpuData>,
214    /// Per-frame GPU implicit surface data, rebuilt in prepare(), consumed in paint() (Phase 16).
215    implicit_gpu_data: Vec<crate::resources::implicit::ImplicitGpuItem>,
216    /// Per-frame GPU marching cubes render data, rebuilt in prepare(), consumed in paint() (Phase 17).
217    mc_gpu_data: Vec<crate::resources::gpu_marching_cubes::McFrameData>,
218    /// Per-frame sprite GPU data, rebuilt in prepare(), consumed in paint().
219    sprite_gpu_data: Vec<crate::resources::SpriteGpuData>,
220    /// Per-frame Gaussian splat draw data, rebuilt in prepare_viewport_internal(), consumed in paint().
221    gaussian_splat_draw_data: Vec<crate::resources::GaussianSplatDrawData>,
222    /// Per-frame screen-image GPU data, rebuilt in prepare(), consumed in paint() (Phase 10B).
223    screen_image_gpu_data: Vec<crate::resources::ScreenImageGpuData>,
224    /// Per-frame overlay image GPU data, rebuilt in prepare(), consumed in paint() (Phase 7).
225    overlay_image_gpu_data: Vec<crate::resources::ScreenImageGpuData>,
226    /// Per-frame overlay label GPU data, rebuilt in prepare(), consumed in paint().
227    label_gpu_data: Option<crate::resources::LabelGpuData>,
228    /// Per-frame scalar bar GPU data, rebuilt in prepare(), consumed in paint().
229    scalar_bar_gpu_data: Option<crate::resources::LabelGpuData>,
230    /// Per-frame ruler GPU data, rebuilt in prepare(), consumed in paint().
231    ruler_gpu_data: Option<crate::resources::LabelGpuData>,
232    /// Per-frame loading bar GPU data, rebuilt in prepare(), consumed in paint().
233    loading_bar_gpu_data: Option<crate::resources::LabelGpuData>,
234    /// Per-viewport GPU state slots.
235    ///
236    /// Indexed by `FrameData::camera.viewport_index`. Each slot owns independent
237    /// uniform buffers and bind groups for camera, clip planes, clip volume,
238    /// shadow info, and grid. Slots are grown lazily in `prepare` via
239    /// `ensure_viewport_slot`. There are at most 4 in the current UI.
240    viewport_slots: Vec<ViewportSlot>,
241    /// Phase G : GPU compute filter results from the last `prepare()` call.
242    ///
243    /// Each entry contains a compacted index buffer + count for one filtered mesh.
244    /// Consumed during `paint()` to override the mesh's default index buffer.
245    /// Cleared and rebuilt each frame.
246    compute_filter_results: Vec<crate::resources::ComputeFilterResult>,
247    /// Per-item uniform buffers for wireframe mode. In wireframe mode multiple scene
248    /// items can share the same MeshId, but each needs its own object uniform (model
249    /// matrix, color, etc.). The mesh's single `object_uniform_buf` gets overwritten
250    /// by the last item prepared, so we maintain a separate pool here. Indexed in the
251    /// same order as the visible scene items. Grown lazily, never shrunk.
252    wireframe_uniform_bufs: Vec<wgpu::Buffer>,
253    /// Bind groups corresponding to `wireframe_uniform_bufs`. Each bind group pairs
254    /// the per-item uniform buffer with the mesh's fallback textures so it is
255    /// compatible with the object bind group layout.
256    wireframe_bind_groups: Vec<wgpu::BindGroup>,
257    /// Cascade-0 light-space view-projection matrix from the last shadow prepare.
258    /// Cached here so `prepare_viewport_internal` can copy it into the ground plane uniform.
259    last_cascade0_shadow_mat: glam::Mat4,
260    /// Current runtime mode controlling internal default behavior.
261    runtime_mode: crate::renderer::stats::RuntimeMode,
262    /// Active performance policy: target FPS, render scale bounds, and permitted reductions.
263    performance_policy: crate::renderer::stats::PerformancePolicy,
264    /// Current render scale tracked by the adaptation controller (or set manually).
265    ///
266    /// Clamped to `[policy.min_render_scale, policy.max_render_scale]`.
267    /// Reported in `FrameStats::render_scale` each frame.
268    current_render_scale: f32,
269    /// Instant recorded at the start of the most recent `prepare()` call.
270    /// Used to compute `total_frame_ms` on the following frame.
271    last_prepare_instant: Option<std::time::Instant>,
272    /// Frame counter incremented each `prepare()` call. Used for picking throttle in Playback mode.
273    frame_counter: u64,
274    /// Surface items from the last `prepare()` call, retained for `pick()` dispatch.
275    pick_scene_items: Vec<SceneRenderItem>,
276    /// Point cloud items from the last `prepare()` call, retained for `pick()` dispatch.
277    pick_point_cloud_items: Vec<PointCloudItem>,
278    /// Gaussian splat items from the last `prepare()` call, retained for `pick()` dispatch.
279    pick_splat_items: Vec<GaussianSplatItem>,
280    /// Volume items from the last `prepare()` call, retained for `pick()` dispatch.
281    pick_volume_items: Vec<VolumeItem>,
282    /// Transparent volume mesh items from the last `prepare()` call, retained for `pick()` dispatch.
283    pick_tvm_items: Vec<TransparentVolumeMeshItem>,
284    /// Opaque volume mesh items from the last `prepare()` call, retained for cell-level `pick()` dispatch.
285    pick_volume_mesh_items: Vec<VolumeMeshItem>,
286    /// Polyline items from the last `prepare()` call, retained for `pick()` dispatch.
287    pick_polyline_items: Vec<PolylineItem>,
288    /// Glyph items from the last `prepare()` call, retained for `pick()` dispatch.
289    pick_glyph_items: Vec<GlyphItem>,
290    /// Tensor glyph items from the last `prepare()` call, retained for `pick()` dispatch.
291    pick_tensor_glyph_items: Vec<TensorGlyphItem>,
292    /// Sprite items from the last `prepare()` call, retained for `pick()` dispatch.
293    pick_sprite_items: Vec<SpriteItem>,
294    /// Streamtube items from the last `prepare()` call, retained for `pick()` dispatch.
295    pick_streamtube_items: Vec<StreamtubeItem>,
296    /// Tube items from the last `prepare()` call, retained for `pick()` dispatch.
297    pick_tube_items: Vec<TubeItem>,
298    /// Ribbon items from the last `prepare()` call, retained for `pick()` dispatch.
299    pick_ribbon_items: Vec<RibbonItem>,
300
301    // --- Phase 4 : GPU timestamp queries ---
302    /// Timestamp query set with 2 entries (scene-pass begin + end).
303    /// `None` when `TIMESTAMP_QUERY` is unavailable or not yet initialized.
304    ts_query_set: Option<wgpu::QuerySet>,
305    /// Resolve buffer: 2 × u64, GPU-only (`QUERY_RESOLVE | COPY_SRC`).
306    ts_resolve_buf: Option<wgpu::Buffer>,
307    /// Staging buffer: 2 × u64, CPU-readable (`COPY_DST | MAP_READ`).
308    ts_staging_buf: Option<wgpu::Buffer>,
309    /// Nanoseconds per GPU timestamp tick, from `queue.get_timestamp_period()`.
310    ts_period: f32,
311    /// Whether the staging buffer holds unread timestamp data from the previous frame.
312    ts_needs_readback: bool,
313
314    // --- Indirect-args readback (GPU-driven culling visible instance count) ---
315    /// CPU-readable staging buffer for `indirect_args_buf` (batch_count × 20 bytes).
316    /// Grown lazily; never shrunk.
317    indirect_readback_buf: Option<wgpu::Buffer>,
318    /// Number of batches whose data was copied into `indirect_readback_buf` last frame.
319    indirect_readback_batch_count: u32,
320    /// True when `indirect_readback_buf` holds unread data from the previous cull pass.
321    indirect_readback_pending: bool,
322
323    // --- Per-pass degradation state (Phases 6 + 11) ---
324    /// Tiered degradation ladder position (0 = none, 1 = shadows, 2 = volumes, 3 = effects).
325    /// Advanced one step per over-budget frame once render scale hits minimum;
326    /// reversed one step per comfortably-under-budget frame.
327    degradation_tier: u8,
328    /// Whether the shadow pass was skipped this frame due to budget pressure.
329    /// Computed once per frame at the top of prepare() and used by both
330    /// prepare_scene_internal and reported in FrameStats.
331    degradation_shadows_skipped: bool,
332    /// Whether volume raymarch step size was doubled this frame due to budget pressure.
333    degradation_volume_quality_reduced: bool,
334    /// Whether SSAO, contact shadows, and bloom were skipped this frame.
335    /// Set in prepare(); read by the render path.
336    degradation_effects_throttled: bool,
337}
338
339impl ViewportRenderer {
340    /// Create a new renderer with default settings (no MSAA).
341    /// Call once at application startup.
342    pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
343        Self::with_sample_count(device, target_format, 1)
344    }
345
346    /// Create a new renderer with the specified MSAA sample count (1, 2, or 4).
347    ///
348    /// When using MSAA (sample_count > 1), the caller must create multisampled
349    /// color and depth textures and use them as render pass attachments with the
350    /// final surface texture as the resolve target.
351    pub fn with_sample_count(
352        device: &wgpu::Device,
353        target_format: wgpu::TextureFormat,
354        sample_count: u32,
355    ) -> Self {
356        let gpu_culling_supported = device
357            .features()
358            .contains(wgpu::Features::INDIRECT_FIRST_INSTANCE);
359        Self {
360            resources: ViewportGpuResources::new(device, target_format, sample_count),
361            instanced_batches: Vec::new(),
362            use_instancing: false,
363            gpu_culling_supported,
364            gpu_culling_enabled: gpu_culling_supported,
365            cull_resources: None,
366            last_stats: crate::renderer::stats::FrameStats::default(),
367            last_scene_generation: u64::MAX,
368            last_selection_generation: u64::MAX,
369            last_scene_items_count: usize::MAX,
370            last_instancable_count: usize::MAX,
371            cached_instance_data: Vec::new(),
372            cached_instanced_batches: Vec::new(),
373            point_cloud_gpu_data: Vec::new(),
374            glyph_gpu_data: Vec::new(),
375            tensor_glyph_gpu_data: Vec::new(),
376            polyline_gpu_data: Vec::new(),
377            volume_gpu_data: Vec::new(),
378            streamtube_gpu_data: Vec::new(),
379            tube_gpu_data: Vec::new(),
380            ribbon_gpu_data: Vec::new(),
381            image_slice_gpu_data: Vec::new(),
382            volume_surface_slice_gpu_data: Vec::new(),
383            sprite_gpu_data: Vec::new(),
384            gaussian_splat_draw_data: Vec::new(),
385            lic_gpu_data: Vec::new(),
386            implicit_gpu_data: Vec::new(),
387            mc_gpu_data: Vec::new(),
388            screen_image_gpu_data: Vec::new(),
389            overlay_image_gpu_data: Vec::new(),
390            label_gpu_data: None,
391            scalar_bar_gpu_data: None,
392            ruler_gpu_data: None,
393            loading_bar_gpu_data: None,
394            viewport_slots: Vec::new(),
395            compute_filter_results: Vec::new(),
396            wireframe_uniform_bufs: Vec::new(),
397            wireframe_bind_groups: Vec::new(),
398            last_cascade0_shadow_mat: glam::Mat4::IDENTITY,
399            runtime_mode: crate::renderer::stats::RuntimeMode::Interactive,
400            performance_policy: crate::renderer::stats::PerformancePolicy::default(),
401            current_render_scale: 1.0,
402            last_prepare_instant: None,
403            frame_counter: 0,
404            pick_scene_items: Vec::new(),
405            pick_point_cloud_items: Vec::new(),
406            pick_splat_items: Vec::new(),
407            pick_volume_items: Vec::new(),
408            pick_tvm_items: Vec::new(),
409            pick_volume_mesh_items: Vec::new(),
410            pick_polyline_items: Vec::new(),
411            pick_glyph_items: Vec::new(),
412            pick_tensor_glyph_items: Vec::new(),
413            pick_sprite_items: Vec::new(),
414            pick_streamtube_items: Vec::new(),
415            pick_tube_items: Vec::new(),
416            pick_ribbon_items: Vec::new(),
417            ts_query_set: None,
418            ts_resolve_buf: None,
419            ts_staging_buf: None,
420            ts_period: 1.0,
421            ts_needs_readback: false,
422            indirect_readback_buf: None,
423            indirect_readback_batch_count: 0,
424            indirect_readback_pending: false,
425            degradation_tier: 0,
426            degradation_shadows_skipped: false,
427            degradation_volume_quality_reduced: false,
428            degradation_effects_throttled: false,
429        }
430    }
431
432    /// Access the underlying GPU resources (e.g. for mesh uploads).
433    pub fn resources(&self) -> &ViewportGpuResources {
434        &self.resources
435    }
436
437    /// Performance counters from the last completed frame.
438    pub fn last_frame_stats(&self) -> crate::renderer::stats::FrameStats {
439        self.last_stats
440    }
441
442    /// Disable GPU-driven culling, reverting to the direct draw path.
443    ///
444    /// Has no effect when the device does not support `INDIRECT_FIRST_INSTANCE`
445    /// (culling is already disabled on those devices).
446    pub fn disable_gpu_driven_culling(&mut self) {
447        self.gpu_culling_enabled = false;
448    }
449
450    /// Re-enable GPU-driven culling after a call to `disable_gpu_driven_culling`.
451    ///
452    /// Has no effect when the device does not support `INDIRECT_FIRST_INSTANCE`.
453    pub fn enable_gpu_driven_culling(&mut self) {
454        if self.gpu_culling_supported {
455            self.gpu_culling_enabled = true;
456        }
457    }
458
459    /// Set the runtime mode controlling internal default behavior.
460    ///
461    /// - [`RuntimeMode::Interactive`]: full picking rate, full quality (default).
462    /// - [`RuntimeMode::Playback`]: picking throttled to reduce CPU overhead during animation.
463    /// - [`RuntimeMode::Paused`]: full picking rate, full quality.
464    /// - [`RuntimeMode::Capture`]: full quality, intended for screenshot/export workflows.
465    pub fn set_runtime_mode(&mut self, mode: crate::renderer::stats::RuntimeMode) {
466        self.runtime_mode = mode;
467    }
468
469    /// Return the current runtime mode.
470    pub fn runtime_mode(&self) -> crate::renderer::stats::RuntimeMode {
471        self.runtime_mode
472    }
473
474    /// Set the performance policy controlling target FPS, render scale bounds,
475    /// and permitted quality reductions.
476    ///
477    /// The internal adaptation controller activates when
478    /// `policy.allow_dynamic_resolution` is `true` and `policy.target_fps` is
479    /// `Some`. It adjusts `render_scale` within `[min_render_scale,
480    /// max_render_scale]` each frame based on `total_frame_ms`.
481    pub fn set_performance_policy(
482        &mut self,
483        policy: crate::renderer::stats::PerformancePolicy,
484    ) {
485        self.performance_policy = policy;
486        // Clamp current scale into the new bounds immediately.
487        self.current_render_scale = self.current_render_scale.clamp(
488            policy.min_render_scale,
489            policy.max_render_scale,
490        );
491    }
492
493    /// Return the active performance policy.
494    pub fn performance_policy(&self) -> crate::renderer::stats::PerformancePolicy {
495        self.performance_policy
496    }
497
498    /// Manually set the render scale.
499    ///
500    /// Effective when `performance_policy.allow_dynamic_resolution` is `false`.
501    /// When dynamic resolution is enabled the adaptation controller overrides
502    /// this value each frame.
503    ///
504    /// The value is clamped to `[policy.min_render_scale, policy.max_render_scale]`.
505    ///
506    /// Has no effect on the HDR render path (`render` / `render_viewport` with
507    /// `PostProcessSettings::enabled = true`). See `allow_dynamic_resolution`.
508    pub fn set_render_scale(&mut self, scale: f32) {
509        self.current_render_scale = scale.clamp(
510            self.performance_policy.min_render_scale,
511            self.performance_policy.max_render_scale,
512        );
513    }
514
515    /// Set the target frame rate used to compute [`FrameStats::missed_budget`].
516    ///
517    /// Convenience wrapper that updates `performance_policy.target_fps`.
518    pub fn set_target_fps(&mut self, fps: Option<f32>) {
519        self.performance_policy.target_fps = fps;
520    }
521
522    /// Mutable access to the underlying GPU resources (e.g. for mesh uploads).
523    pub fn resources_mut(&mut self) -> &mut ViewportGpuResources {
524        &mut self.resources
525    }
526
527    /// Upload a Gaussian splat set to the GPU.
528    ///
529    /// Call once per splat set at startup or when it changes. The returned
530    /// [`GaussianSplatId`] is valid until [`remove_gaussian_splats`](Self::remove_gaussian_splats) is called.
531    pub fn upload_gaussian_splats(
532        &mut self,
533        device: &wgpu::Device,
534        queue: &wgpu::Queue,
535        data: &GaussianSplatData,
536    ) -> GaussianSplatId {
537        self.resources.upload_gaussian_splats(device, queue, data)
538    }
539
540    /// Remove an uploaded Gaussian splat set by handle.
541    ///
542    /// After this call the `id` is invalid and must not be submitted in `SceneFrame`.
543    pub fn remove_gaussian_splats(&mut self, id: GaussianSplatId) {
544        self.resources.remove_gaussian_splats(id);
545    }
546
547    /// Upload an equirectangular HDR environment map and precompute IBL textures.
548    ///
549    /// `pixels` is row-major RGBA f32 data (4 floats per texel), `width`×`height`.
550    /// This rebuilds camera bind groups so shaders immediately see the new textures.
551    pub fn upload_environment_map(
552        &mut self,
553        device: &wgpu::Device,
554        queue: &wgpu::Queue,
555        pixels: &[f32],
556        width: u32,
557        height: u32,
558    ) {
559        crate::resources::environment::upload_environment_map(
560            &mut self.resources,
561            device,
562            queue,
563            pixels,
564            width,
565            height,
566        );
567        self.rebuild_camera_bind_groups(device);
568    }
569
570    /// Rebuild the primary + per-viewport camera bind groups.
571    ///
572    /// Call after IBL textures are uploaded so shaders see the new environment.
573    fn rebuild_camera_bind_groups(&mut self, device: &wgpu::Device) {
574        self.resources.camera_bind_group = self.resources.create_camera_bind_group(
575            device,
576            &self.resources.camera_uniform_buf,
577            &self.resources.clip_planes_uniform_buf,
578            &self.resources.shadow_info_buf,
579            &self.resources.clip_volume_uniform_buf,
580            "camera_bind_group",
581        );
582
583        for slot in &mut self.viewport_slots {
584            slot.camera_bind_group = self.resources.create_camera_bind_group(
585                device,
586                &slot.camera_buf,
587                &slot.clip_planes_buf,
588                &slot.shadow_info_buf,
589                &slot.clip_volume_buf,
590                "per_viewport_camera_bg",
591            );
592        }
593    }
594
595    /// Ensure a per-viewport slot exists for `viewport_index`.
596    ///
597    /// Creates a full `ViewportSlot` with independent uniform buffers for camera,
598    /// clip planes, clip volume, shadow info, and grid. The camera bind group
599    /// references this slot's per-viewport buffers plus shared scene-global
600    /// resources. Slots are created lazily and never destroyed.
601    fn ensure_viewport_slot(&mut self, device: &wgpu::Device, viewport_index: usize) {
602        while self.viewport_slots.len() <= viewport_index {
603            let camera_buf = device.create_buffer(&wgpu::BufferDescriptor {
604                label: Some("vp_camera_buf"),
605                size: std::mem::size_of::<CameraUniform>() as u64,
606                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
607                mapped_at_creation: false,
608            });
609            let clip_planes_buf = device.create_buffer(&wgpu::BufferDescriptor {
610                label: Some("vp_clip_planes_buf"),
611                size: std::mem::size_of::<ClipPlanesUniform>() as u64,
612                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
613                mapped_at_creation: false,
614            });
615            let clip_volume_buf = device.create_buffer(&wgpu::BufferDescriptor {
616                label: Some("vp_clip_volume_buf"),
617                size: std::mem::size_of::<ClipVolumesUniform>() as u64,
618                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
619                mapped_at_creation: false,
620            });
621            let shadow_info_buf = device.create_buffer(&wgpu::BufferDescriptor {
622                label: Some("vp_shadow_info_buf"),
623                size: std::mem::size_of::<ShadowAtlasUniform>() as u64,
624                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
625                mapped_at_creation: false,
626            });
627            let grid_buf = device.create_buffer(&wgpu::BufferDescriptor {
628                label: Some("vp_grid_buf"),
629                size: std::mem::size_of::<GridUniform>() as u64,
630                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
631                mapped_at_creation: false,
632            });
633
634            let camera_bind_group = self.resources.create_camera_bind_group(
635                device,
636                &camera_buf,
637                &clip_planes_buf,
638                &shadow_info_buf,
639                &clip_volume_buf,
640                "per_viewport_camera_bg",
641            );
642
643            let grid_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
644                label: Some("vp_grid_bind_group"),
645                layout: &self.resources.grid_bind_group_layout,
646                entries: &[wgpu::BindGroupEntry {
647                    binding: 0,
648                    resource: grid_buf.as_entire_binding(),
649                }],
650            });
651
652            // Per-viewport gizmo buffers (initial mesh: Translate, no hover, identity orientation).
653            let (gizmo_verts, gizmo_indices) = crate::interaction::gizmo::build_gizmo_mesh(
654                crate::interaction::gizmo::GizmoMode::Translate,
655                crate::interaction::gizmo::GizmoAxis::None,
656                glam::Quat::IDENTITY,
657            );
658            let gizmo_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
659                label: Some("vp_gizmo_vertex_buf"),
660                size: (std::mem::size_of::<crate::resources::Vertex>() * gizmo_verts.len().max(1))
661                    as u64,
662                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
663                mapped_at_creation: true,
664            });
665            gizmo_vertex_buffer
666                .slice(..)
667                .get_mapped_range_mut()
668                .copy_from_slice(bytemuck::cast_slice(&gizmo_verts));
669            gizmo_vertex_buffer.unmap();
670            let gizmo_index_count = gizmo_indices.len() as u32;
671            let gizmo_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
672                label: Some("vp_gizmo_index_buf"),
673                size: (std::mem::size_of::<u32>() * gizmo_indices.len().max(1)) as u64,
674                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
675                mapped_at_creation: true,
676            });
677            gizmo_index_buffer
678                .slice(..)
679                .get_mapped_range_mut()
680                .copy_from_slice(bytemuck::cast_slice(&gizmo_indices));
681            gizmo_index_buffer.unmap();
682            let gizmo_uniform = crate::interaction::gizmo::GizmoUniform {
683                model: glam::Mat4::IDENTITY.to_cols_array_2d(),
684            };
685            let gizmo_uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
686                label: Some("vp_gizmo_uniform_buf"),
687                size: std::mem::size_of::<crate::interaction::gizmo::GizmoUniform>() as u64,
688                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
689                mapped_at_creation: true,
690            });
691            gizmo_uniform_buf
692                .slice(..)
693                .get_mapped_range_mut()
694                .copy_from_slice(bytemuck::cast_slice(&[gizmo_uniform]));
695            gizmo_uniform_buf.unmap();
696            let gizmo_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
697                label: Some("vp_gizmo_bind_group"),
698                layout: &self.resources.gizmo_bind_group_layout,
699                entries: &[wgpu::BindGroupEntry {
700                    binding: 0,
701                    resource: gizmo_uniform_buf.as_entire_binding(),
702                }],
703            });
704
705            // Per-viewport axes vertex buffer (2048 vertices = enough for all axes geometry).
706            let axes_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
707                label: Some("vp_axes_vertex_buf"),
708                size: (std::mem::size_of::<crate::widgets::axes_indicator::AxesVertex>() * 2048)
709                    as u64,
710                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
711                mapped_at_creation: false,
712            });
713
714            self.viewport_slots.push(ViewportSlot {
715                camera_buf,
716                clip_planes_buf,
717                clip_volume_buf,
718                shadow_info_buf,
719                grid_buf,
720                camera_bind_group,
721                grid_bind_group,
722                hdr: None,
723                outline_object_buffers: Vec::new(),
724                splat_outline_buffers: Vec::new(),
725                volume_outline_indices: Vec::new(),
726                glyph_outline_indices: Vec::new(),
727                tensor_glyph_outline_indices: Vec::new(),
728                sprite_outline_indices: Vec::new(),
729                xray_object_buffers: Vec::new(),
730                constraint_line_buffers: Vec::new(),
731                cap_buffers: Vec::new(),
732                clip_plane_fill_buffers: Vec::new(),
733                clip_plane_line_buffers: Vec::new(),
734                axes_vertex_buffer,
735                axes_vertex_count: 0,
736                gizmo_uniform_buf,
737                gizmo_bind_group,
738                gizmo_vertex_buffer,
739                gizmo_index_buffer,
740                gizmo_index_count,
741                sub_highlight: None,
742                sub_highlight_generation: u64::MAX,
743                dyn_res: None,
744                hdr_callback: None,
745            });
746        }
747    }
748
749    // -----------------------------------------------------------------------
750    // Multi-viewport public API (Phase 5)
751    // -----------------------------------------------------------------------
752
753    /// Create a new viewport slot and return its handle.
754    ///
755    /// The returned [`ViewportId`] is stable for the lifetime of the renderer.
756    /// Pass it to [`prepare_viewport`](Self::prepare_viewport),
757    /// [`paint_viewport`](Self::paint_viewport), and
758    /// [`render_viewport`](Self::render_viewport) each frame.
759    ///
760    /// Also set `CameraFrame::viewport_index` to `id.0` when building the
761    /// [`FrameData`] for this viewport:
762    /// ```rust,ignore
763    /// let id = renderer.create_viewport(&device);
764    /// let frame = FrameData {
765    ///     camera: CameraFrame::from_camera(&cam, size).with_viewport_index(id.0),
766    ///     ..Default::default()
767    /// };
768    /// ```
769    pub fn create_viewport(&mut self, device: &wgpu::Device) -> ViewportId {
770        let idx = self.viewport_slots.len();
771        self.ensure_viewport_slot(device, idx);
772        ViewportId(idx)
773    }
774
775    /// Release the heavy GPU texture memory (HDR targets, OIT, bloom, SSAO) held
776    /// by `id`.
777    ///
778    /// The slot index is not reclaimed : future calls with this `ViewportId` will
779    /// lazily recreate the texture resources as needed.  This is useful when a
780    /// viewport is hidden or minimised and you want to reduce VRAM pressure without
781    /// invalidating the handle.
782    pub fn destroy_viewport(&mut self, id: ViewportId) {
783        if let Some(slot) = self.viewport_slots.get_mut(id.0) {
784            slot.hdr = None;
785        }
786    }
787
788    /// Prepare shared scene data.  Call **once per frame**, before any
789    /// [`prepare_viewport`](Self::prepare_viewport) calls.
790    ///
791    /// `frame` provides the scene content (`frame.scene`) and the primary camera
792    /// used for shadow cascade framing (`frame.camera`).  In a multi-viewport
793    /// setup use any one viewport's `FrameData` here : typically the perspective
794    /// view : as the shadow framing reference.
795    ///
796    /// `scene_effects` carries the scene-global effects: lighting, environment
797    /// map, and compute filters.  Obtain it by constructing [`SceneEffects`]
798    /// directly or via [`EffectsFrame::split`].
799    pub fn prepare_scene(
800        &mut self,
801        device: &wgpu::Device,
802        queue: &wgpu::Queue,
803        frame: &FrameData,
804        scene_effects: &SceneEffects<'_>,
805    ) {
806        self.prepare_scene_internal(device, queue, frame, scene_effects);
807    }
808
809    /// Prepare per-viewport GPU state (camera, clip planes, overlays, axes).
810    ///
811    /// Call once per viewport per frame, **after** [`prepare_scene`](Self::prepare_scene).
812    ///
813    /// `id` must have been obtained from [`create_viewport`](Self::create_viewport).
814    /// `frame.camera.viewport_index` must equal `id.0`; use
815    /// [`CameraFrame::with_viewport_index`] when building the frame.
816    pub fn prepare_viewport(
817        &mut self,
818        device: &wgpu::Device,
819        queue: &wgpu::Queue,
820        id: ViewportId,
821        frame: &FrameData,
822    ) {
823        debug_assert_eq!(
824            frame.camera.viewport_index, id.0,
825            "frame.camera.viewport_index ({}) must equal the ViewportId ({}); \
826             use CameraFrame::with_viewport_index(id.0)",
827            frame.camera.viewport_index, id.0,
828        );
829        let (_, viewport_fx) = frame.effects.split();
830        self.prepare_viewport_internal(device, queue, frame, &viewport_fx);
831    }
832
833    /// Issue draw calls for `id` into a `'static` render pass (as provided by egui callbacks).
834    ///
835    /// This is the method to use from an egui/eframe `CallbackTrait::paint` implementation.
836    /// Call [`prepare_scene`](Self::prepare_scene) and [`prepare_viewport`](Self::prepare_viewport)
837    /// first (in `CallbackTrait::prepare`), then set the render pass viewport/scissor to confine
838    /// drawing to the correct quadrant, and call this method.
839    ///
840    /// For non-`'static` render passes (winit, iced, manual wgpu), use
841    /// [`paint_viewport_to`](Self::paint_viewport_to).
842    pub fn paint_viewport(
843        &self,
844        render_pass: &mut wgpu::RenderPass<'static>,
845        id: ViewportId,
846        frame: &FrameData,
847    ) {
848        let vp_idx = id.0;
849        let camera_bg = self.viewport_camera_bind_group(vp_idx);
850        let grid_bg = self.viewport_grid_bind_group(vp_idx);
851        let vp_slot = self.viewport_slots.get(vp_idx);
852        emit_draw_calls!(
853            &self.resources,
854            &mut *render_pass,
855            frame,
856            self.use_instancing,
857            &self.instanced_batches,
858            camera_bg,
859            grid_bg,
860            &self.compute_filter_results,
861            vp_slot,
862            &self.wireframe_bind_groups
863        );
864        emit_scivis_draw_calls!(
865            &self.resources,
866            &mut *render_pass,
867            &self.point_cloud_gpu_data,
868            &self.glyph_gpu_data,
869            &self.polyline_gpu_data,
870            &self.volume_gpu_data,
871            &self.streamtube_gpu_data,
872            camera_bg,
873            &self.tube_gpu_data,
874            &self.image_slice_gpu_data,
875            &self.tensor_glyph_gpu_data,
876            &self.ribbon_gpu_data,
877            &self.volume_surface_slice_gpu_data,
878            &self.sprite_gpu_data,
879            false
880        );
881        // Gaussian splats (alpha-blended, back-to-front sorted, no depth write).
882        if !self.gaussian_splat_draw_data.is_empty() {
883            if let Some(ref dual) = self.resources.gaussian_splat_pipeline {
884                render_pass.set_pipeline(dual.for_format(false));
885                render_pass.set_bind_group(0, camera_bg, &[]);
886                for dd in &self.gaussian_splat_draw_data {
887                    if let Some(set) = self.resources.gaussian_splat_store.get(dd.store_index) {
888                        if let Some(Some(vp_sort)) = set.viewport_sort.get(dd.viewport_index) {
889                            render_pass.set_bind_group(1, &vp_sort.render_bg, &[]);
890                            render_pass.draw(0..6, 0..dd.count);
891                        }
892                    }
893                }
894            }
895        }
896    }
897
898    /// Issue draw calls for `id` into a render pass with any lifetime.
899    ///
900    /// Identical to [`paint_viewport`](Self::paint_viewport) but accepts a render pass with a
901    /// non-`'static` lifetime, making it usable from winit, iced, or raw wgpu where the encoder
902    /// creates its own render pass.
903    pub fn paint_viewport_to<'rp>(
904        &'rp self,
905        render_pass: &mut wgpu::RenderPass<'rp>,
906        id: ViewportId,
907        frame: &FrameData,
908    ) {
909        let vp_idx = id.0;
910        let camera_bg = self.viewport_camera_bind_group(vp_idx);
911        let grid_bg = self.viewport_grid_bind_group(vp_idx);
912        let vp_slot = self.viewport_slots.get(vp_idx);
913        emit_draw_calls!(
914            &self.resources,
915            &mut *render_pass,
916            frame,
917            self.use_instancing,
918            &self.instanced_batches,
919            camera_bg,
920            grid_bg,
921            &self.compute_filter_results,
922            vp_slot,
923            &self.wireframe_bind_groups
924        );
925        emit_scivis_draw_calls!(
926            &self.resources,
927            &mut *render_pass,
928            &self.point_cloud_gpu_data,
929            &self.glyph_gpu_data,
930            &self.polyline_gpu_data,
931            &self.volume_gpu_data,
932            &self.streamtube_gpu_data,
933            camera_bg,
934            &self.tube_gpu_data,
935            &self.image_slice_gpu_data,
936            &self.tensor_glyph_gpu_data,
937            &self.ribbon_gpu_data,
938            &self.volume_surface_slice_gpu_data,
939            &self.sprite_gpu_data,
940            false
941        );
942        // Gaussian splats (alpha-blended, back-to-front sorted, no depth write).
943        if !self.gaussian_splat_draw_data.is_empty() {
944            if let Some(ref dual) = self.resources.gaussian_splat_pipeline {
945                render_pass.set_pipeline(dual.for_format(false));
946                render_pass.set_bind_group(0, camera_bg, &[]);
947                for dd in &self.gaussian_splat_draw_data {
948                    if let Some(set) = self.resources.gaussian_splat_store.get(dd.store_index) {
949                        if let Some(Some(vp_sort)) = set.viewport_sort.get(dd.viewport_index) {
950                            render_pass.set_bind_group(1, &vp_sort.render_bg, &[]);
951                            render_pass.draw(0..6, 0..dd.count);
952                        }
953                    }
954                }
955            }
956        }
957    }
958
959    /// Return a reference to the camera bind group for the given viewport slot.
960    ///
961    /// Falls back to `resources.camera_bind_group` if no per-viewport slot
962    /// exists (e.g. in single-viewport mode before the first prepare call).
963    fn viewport_camera_bind_group(&self, viewport_index: usize) -> &wgpu::BindGroup {
964        self.viewport_slots
965            .get(viewport_index)
966            .map(|slot| &slot.camera_bind_group)
967            .unwrap_or(&self.resources.camera_bind_group)
968    }
969
970    /// Return a reference to the grid bind group for the given viewport slot.
971    ///
972    /// Falls back to `resources.grid_bind_group` if no per-viewport slot exists.
973    fn viewport_grid_bind_group(&self, viewport_index: usize) -> &wgpu::BindGroup {
974        self.viewport_slots
975            .get(viewport_index)
976            .map(|slot| &slot.grid_bind_group)
977            .unwrap_or(&self.resources.grid_bind_group)
978    }
979
980    /// Ensure the dyn-res intermediate render target exists for `vp_idx` at the
981    /// given `scaled_size`, creating or recreating it when size changes.
982    ///
983    /// `surface_size` is the native output dimensions (used to size the upscale
984    /// blit correctly). `ensure_dyn_res_pipeline` is called automatically.
985    pub(crate) fn ensure_dyn_res_target(
986        &mut self,
987        device: &wgpu::Device,
988        vp_idx: usize,
989        scaled_size: [u32; 2],
990        surface_size: [u32; 2],
991    ) {
992        self.resources.ensure_dyn_res_pipeline(device);
993        let needs_create = match &self.viewport_slots[vp_idx].dyn_res {
994            None => true,
995            Some(dr) => dr.scaled_size != scaled_size || dr.surface_size != surface_size,
996        };
997        if needs_create {
998            let target =
999                self.resources.create_dyn_res_target(device, scaled_size, surface_size);
1000            self.viewport_slots[vp_idx].dyn_res = Some(target);
1001        }
1002    }
1003
1004    /// Ensure per-viewport HDR state exists for `viewport_index` at dimensions `w`×`h`.
1005    ///
1006    /// Calls `ensure_hdr_shared` once to initialise shared pipelines/BGLs/samplers, then
1007    /// lazily creates or resizes the `ViewportHdrState` inside the slot. Idempotent: if the
1008    /// slot already has HDR state at the correct size nothing is recreated.
1009    pub(crate) fn ensure_viewport_hdr(
1010        &mut self,
1011        device: &wgpu::Device,
1012        queue: &wgpu::Queue,
1013        viewport_index: usize,
1014        w: u32,
1015        h: u32,
1016        ssaa_factor: u32,
1017    ) {
1018        let format = self.resources.target_format;
1019        // Ensure shared infrastructure (pipelines, BGLs, samplers) exists.
1020        self.resources.ensure_hdr_shared(device, queue, format);
1021        // Ensure the slot exists.
1022        self.ensure_viewport_slot(device, viewport_index);
1023        let slot = &mut self.viewport_slots[viewport_index];
1024        // Create or resize the per-viewport HDR state.
1025        let needs_create = match &slot.hdr {
1026            None => true,
1027            Some(h_state) => h_state.size != [w, h] || h_state.ssaa_factor != ssaa_factor,
1028        };
1029        if needs_create {
1030            slot.hdr = Some(self.resources.create_hdr_viewport_state(
1031                device,
1032                queue,
1033                format,
1034                w,
1035                h,
1036                ssaa_factor,
1037            ));
1038        }
1039    }
1040}