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 picking;
10mod prepare;
11mod render;
12pub mod shader_hashes;
13mod shadows;
14pub mod stats;
15
16pub use self::types::{
17    CameraFrame, CameraFrustumItem, ClipObject, ClipShape, ComputeFilterItem, ComputeFilterKind,
18    EffectsFrame, EnvironmentMap, FilterMode, FrameData, GlyphItem, GlyphType, GroundPlane,
19    GroundPlaneMode, ImageAnchor, InteractionFrame, LightKind, LightSource, LightingSettings,
20    PointCloudItem, PointRenderMode, PolylineItem, PostProcessSettings, RenderCamera, ScalarBar,
21    ScalarBarAnchor, ScalarBarOrientation, SceneEffects, SceneFrame, SceneRenderItem,
22    ScreenImageItem, ShadowFilter, StreamtubeItem, SurfaceSubmission, ToneMapping, ViewportEffects,
23    ViewportFrame, VolumeItem,
24};
25pub(crate) use self::types::ClipPlane;
26
27/// An opaque handle to a per-viewport GPU state slot.
28///
29/// Obtained from [`ViewportRenderer::create_viewport`] and passed to
30/// [`ViewportRenderer::prepare_viewport`], [`ViewportRenderer::paint_viewport`],
31/// and [`ViewportRenderer::render_viewport`].
32///
33/// The inner `usize` is the slot index and doubles as the value for
34/// [`CameraFrame::with_viewport_index`].  Single-viewport applications that use
35/// the legacy [`ViewportRenderer::prepare`] / [`ViewportRenderer::paint`] API do
36/// not need this type.
37#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
38pub struct ViewportId(pub usize);
39
40use self::shadows::{compute_cascade_matrix, compute_cascade_splits};
41use self::types::{INSTANCING_THRESHOLD, InstancedBatch};
42use crate::resources::{
43    CameraUniform, ClipPlanesUniform, ClipVolumeUniform, GridUniform, InstanceData, LightsUniform,
44    ObjectUniform, OutlineObjectBuffers, OutlineUniform, PickInstance, ShadowAtlasUniform,
45    SingleLightUniform, ViewportGpuResources,
46};
47
48/// Per-viewport GPU state: uniform buffers and bind groups that differ per viewport.
49///
50/// Each viewport slot owns its own camera, clip planes, clip volume, shadow info,
51/// and grid buffers, plus the bind groups that reference them. Scene-global
52/// resources (lights, shadow atlas texture, IBL) are shared via the bind group
53/// pointing to buffers on `ViewportGpuResources`.
54pub(crate) struct ViewportSlot {
55    pub camera_buf: wgpu::Buffer,
56    pub clip_planes_buf: wgpu::Buffer,
57    pub clip_volume_buf: wgpu::Buffer,
58    pub shadow_info_buf: wgpu::Buffer,
59    pub grid_buf: wgpu::Buffer,
60    /// Camera bind group (group 0) referencing this slot's per-viewport buffers
61    /// plus shared scene-global resources.
62    pub camera_bind_group: wgpu::BindGroup,
63    /// Grid bind group (group 0 for grid pipeline) referencing this slot's grid buffer.
64    pub grid_bind_group: wgpu::BindGroup,
65    /// Per-viewport HDR post-process render targets.
66    ///
67    /// Created lazily on first HDR render call and resized when viewport dimensions change.
68    pub hdr: Option<crate::resources::ViewportHdrState>,
69
70    // --- Per-viewport interaction state (Phase 4) ---
71    /// Per-frame outline buffers for selected objects, rebuilt in prepare().
72    pub outline_object_buffers: Vec<OutlineObjectBuffers>,
73    /// Per-frame x-ray buffers for selected objects, rebuilt in prepare().
74    pub xray_object_buffers: Vec<(usize, wgpu::Buffer, wgpu::BindGroup)>,
75    /// Per-frame constraint guide line buffers, rebuilt in prepare().
76    pub constraint_line_buffers: Vec<(
77        wgpu::Buffer,
78        wgpu::Buffer,
79        u32,
80        wgpu::Buffer,
81        wgpu::BindGroup,
82    )>,
83    /// Per-frame cap geometry buffers (section view cross-section fill), rebuilt in prepare().
84    pub cap_buffers: Vec<(
85        wgpu::Buffer,
86        wgpu::Buffer,
87        u32,
88        wgpu::Buffer,
89        wgpu::BindGroup,
90    )>,
91    /// Per-frame clip plane fill overlay buffers, rebuilt in prepare().
92    pub clip_plane_fill_buffers: Vec<(
93        wgpu::Buffer,
94        wgpu::Buffer,
95        u32,
96        wgpu::Buffer,
97        wgpu::BindGroup,
98    )>,
99    /// Per-frame clip plane line overlay buffers, rebuilt in prepare().
100    pub clip_plane_line_buffers: Vec<(
101        wgpu::Buffer,
102        wgpu::Buffer,
103        u32,
104        wgpu::Buffer,
105        wgpu::BindGroup,
106    )>,
107    /// Vertex buffer for axes indicator geometry (rebuilt each frame).
108    pub axes_vertex_buffer: wgpu::Buffer,
109    /// Number of vertices in the axes indicator buffer.
110    pub axes_vertex_count: u32,
111    /// Gizmo model-matrix uniform buffer.
112    pub gizmo_uniform_buf: wgpu::Buffer,
113    /// Gizmo bind group (group 1: model matrix uniform).
114    pub gizmo_bind_group: wgpu::BindGroup,
115    /// Gizmo vertex buffer.
116    pub gizmo_vertex_buffer: wgpu::Buffer,
117    /// Gizmo index buffer.
118    pub gizmo_index_buffer: wgpu::Buffer,
119    /// Number of indices in the current gizmo mesh.
120    pub gizmo_index_count: u32,
121}
122
123/// High-level renderer wrapping all GPU resources and providing framework-agnostic
124/// `prepare()` and `paint()` methods.
125pub struct ViewportRenderer {
126    resources: ViewportGpuResources,
127    /// Instanced batches prepared for the current frame. Empty when using per-object path.
128    instanced_batches: Vec<InstancedBatch>,
129    /// Whether the current frame uses the instanced draw path.
130    use_instancing: bool,
131    /// Performance counters from the last frame.
132    last_stats: crate::renderer::stats::FrameStats,
133    /// Last scene generation seen during prepare(). u64::MAX forces rebuild on first frame.
134    last_scene_generation: u64,
135    /// Last selection generation seen during prepare(). u64::MAX forces rebuild on first frame.
136    last_selection_generation: u64,
137    /// Last scene_items count seen during prepare(). usize::MAX forces rebuild on first frame.
138    /// Included in cache key so that frustum-culling changes (different visible set, different
139    /// count) correctly invalidate the instance buffer even when scene_generation is stable.
140    last_scene_items_count: usize,
141    /// Cached instance data from last rebuild (mirrors the GPU buffer contents).
142    cached_instance_data: Vec<InstanceData>,
143    /// Cached instanced batch descriptors from last rebuild.
144    cached_instanced_batches: Vec<InstancedBatch>,
145    /// Per-frame point cloud GPU data, rebuilt in prepare(), consumed in paint().
146    point_cloud_gpu_data: Vec<crate::resources::PointCloudGpuData>,
147    /// Per-frame glyph GPU data, rebuilt in prepare(), consumed in paint().
148    glyph_gpu_data: Vec<crate::resources::GlyphGpuData>,
149    /// Per-frame polyline GPU data, rebuilt in prepare(), consumed in paint().
150    polyline_gpu_data: Vec<crate::resources::PolylineGpuData>,
151    /// Per-frame volume GPU data, rebuilt in prepare(), consumed in paint().
152    volume_gpu_data: Vec<crate::resources::VolumeGpuData>,
153    /// Per-frame streamtube GPU data, rebuilt in prepare(), consumed in paint().
154    streamtube_gpu_data: Vec<crate::resources::StreamtubeGpuData>,
155    /// Per-frame screen-image GPU data, rebuilt in prepare(), consumed in paint() (Phase 10B).
156    screen_image_gpu_data: Vec<crate::resources::ScreenImageGpuData>,
157    /// Per-viewport GPU state slots.
158    ///
159    /// Indexed by `FrameData::camera.viewport_index`. Each slot owns independent
160    /// uniform buffers and bind groups for camera, clip planes, clip volume,
161    /// shadow info, and grid. Slots are grown lazily in `prepare` via
162    /// `ensure_viewport_slot`. There are at most 4 in the current UI.
163    viewport_slots: Vec<ViewportSlot>,
164    /// Phase G — GPU compute filter results from the last `prepare()` call.
165    ///
166    /// Each entry contains a compacted index buffer + count for one filtered mesh.
167    /// Consumed during `paint()` to override the mesh's default index buffer.
168    /// Cleared and rebuilt each frame.
169    compute_filter_results: Vec<crate::resources::ComputeFilterResult>,
170    /// Cascade-0 light-space view-projection matrix from the last shadow prepare.
171    /// Cached here so `prepare_viewport_internal` can copy it into the ground plane uniform.
172    last_cascade0_shadow_mat: glam::Mat4,
173}
174
175impl ViewportRenderer {
176    /// Create a new renderer with default settings (no MSAA).
177    /// Call once at application startup.
178    pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
179        Self::with_sample_count(device, target_format, 1)
180    }
181
182    /// Create a new renderer with the specified MSAA sample count (1, 2, or 4).
183    ///
184    /// When using MSAA (sample_count > 1), the caller must create multisampled
185    /// color and depth textures and use them as render pass attachments with the
186    /// final surface texture as the resolve target.
187    pub fn with_sample_count(
188        device: &wgpu::Device,
189        target_format: wgpu::TextureFormat,
190        sample_count: u32,
191    ) -> Self {
192        Self {
193            resources: ViewportGpuResources::new(device, target_format, sample_count),
194            instanced_batches: Vec::new(),
195            use_instancing: false,
196            last_stats: crate::renderer::stats::FrameStats::default(),
197            last_scene_generation: u64::MAX,
198            last_selection_generation: u64::MAX,
199            last_scene_items_count: usize::MAX,
200            cached_instance_data: Vec::new(),
201            cached_instanced_batches: Vec::new(),
202            point_cloud_gpu_data: Vec::new(),
203            glyph_gpu_data: Vec::new(),
204            polyline_gpu_data: Vec::new(),
205            volume_gpu_data: Vec::new(),
206            streamtube_gpu_data: Vec::new(),
207            screen_image_gpu_data: Vec::new(),
208            viewport_slots: Vec::new(),
209            compute_filter_results: Vec::new(),
210            last_cascade0_shadow_mat: glam::Mat4::IDENTITY,
211        }
212    }
213
214    /// Access the underlying GPU resources (e.g. for mesh uploads).
215    pub fn resources(&self) -> &ViewportGpuResources {
216        &self.resources
217    }
218
219    /// Performance counters from the last completed frame.
220    pub fn last_frame_stats(&self) -> crate::renderer::stats::FrameStats {
221        self.last_stats
222    }
223
224    /// Mutable access to the underlying GPU resources (e.g. for mesh uploads).
225    pub fn resources_mut(&mut self) -> &mut ViewportGpuResources {
226        &mut self.resources
227    }
228
229    /// Upload an equirectangular HDR environment map and precompute IBL textures.
230    ///
231    /// `pixels` is row-major RGBA f32 data (4 floats per texel), `width`×`height`.
232    /// This rebuilds camera bind groups so shaders immediately see the new textures.
233    pub fn upload_environment_map(
234        &mut self,
235        device: &wgpu::Device,
236        queue: &wgpu::Queue,
237        pixels: &[f32],
238        width: u32,
239        height: u32,
240    ) {
241        crate::resources::environment::upload_environment_map(
242            &mut self.resources,
243            device,
244            queue,
245            pixels,
246            width,
247            height,
248        );
249        self.rebuild_camera_bind_groups(device);
250    }
251
252    /// Rebuild the primary + per-viewport camera bind groups.
253    ///
254    /// Call after IBL textures are uploaded so shaders see the new environment.
255    fn rebuild_camera_bind_groups(&mut self, device: &wgpu::Device) {
256        self.resources.camera_bind_group = self.resources.create_camera_bind_group(
257            device,
258            &self.resources.camera_uniform_buf,
259            &self.resources.clip_planes_uniform_buf,
260            &self.resources.shadow_info_buf,
261            &self.resources.clip_volume_uniform_buf,
262            "camera_bind_group",
263        );
264
265        for slot in &mut self.viewport_slots {
266            slot.camera_bind_group = self.resources.create_camera_bind_group(
267                device,
268                &slot.camera_buf,
269                &slot.clip_planes_buf,
270                &slot.shadow_info_buf,
271                &slot.clip_volume_buf,
272                "per_viewport_camera_bg",
273            );
274        }
275    }
276
277    /// Ensure a per-viewport slot exists for `viewport_index`.
278    ///
279    /// Creates a full `ViewportSlot` with independent uniform buffers for camera,
280    /// clip planes, clip volume, shadow info, and grid. The camera bind group
281    /// references this slot's per-viewport buffers plus shared scene-global
282    /// resources. Slots are created lazily and never destroyed.
283    fn ensure_viewport_slot(&mut self, device: &wgpu::Device, viewport_index: usize) {
284        while self.viewport_slots.len() <= viewport_index {
285            let camera_buf = device.create_buffer(&wgpu::BufferDescriptor {
286                label: Some("vp_camera_buf"),
287                size: std::mem::size_of::<CameraUniform>() as u64,
288                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
289                mapped_at_creation: false,
290            });
291            let clip_planes_buf = device.create_buffer(&wgpu::BufferDescriptor {
292                label: Some("vp_clip_planes_buf"),
293                size: std::mem::size_of::<ClipPlanesUniform>() as u64,
294                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
295                mapped_at_creation: false,
296            });
297            let clip_volume_buf = device.create_buffer(&wgpu::BufferDescriptor {
298                label: Some("vp_clip_volume_buf"),
299                size: std::mem::size_of::<ClipVolumeUniform>() as u64,
300                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
301                mapped_at_creation: false,
302            });
303            let shadow_info_buf = device.create_buffer(&wgpu::BufferDescriptor {
304                label: Some("vp_shadow_info_buf"),
305                size: std::mem::size_of::<ShadowAtlasUniform>() as u64,
306                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
307                mapped_at_creation: false,
308            });
309            let grid_buf = device.create_buffer(&wgpu::BufferDescriptor {
310                label: Some("vp_grid_buf"),
311                size: std::mem::size_of::<GridUniform>() as u64,
312                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
313                mapped_at_creation: false,
314            });
315
316            let camera_bind_group = self.resources.create_camera_bind_group(
317                device,
318                &camera_buf,
319                &clip_planes_buf,
320                &shadow_info_buf,
321                &clip_volume_buf,
322                "per_viewport_camera_bg",
323            );
324
325            let grid_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
326                label: Some("vp_grid_bind_group"),
327                layout: &self.resources.grid_bind_group_layout,
328                entries: &[wgpu::BindGroupEntry {
329                    binding: 0,
330                    resource: grid_buf.as_entire_binding(),
331                }],
332            });
333
334            // Per-viewport gizmo buffers (initial mesh: Translate, no hover, identity orientation).
335            let (gizmo_verts, gizmo_indices) = crate::interaction::gizmo::build_gizmo_mesh(
336                crate::interaction::gizmo::GizmoMode::Translate,
337                crate::interaction::gizmo::GizmoAxis::None,
338                glam::Quat::IDENTITY,
339            );
340            let gizmo_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
341                label: Some("vp_gizmo_vertex_buf"),
342                size: (std::mem::size_of::<crate::resources::Vertex>() * gizmo_verts.len().max(1))
343                    as u64,
344                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
345                mapped_at_creation: true,
346            });
347            gizmo_vertex_buffer
348                .slice(..)
349                .get_mapped_range_mut()
350                .copy_from_slice(bytemuck::cast_slice(&gizmo_verts));
351            gizmo_vertex_buffer.unmap();
352            let gizmo_index_count = gizmo_indices.len() as u32;
353            let gizmo_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
354                label: Some("vp_gizmo_index_buf"),
355                size: (std::mem::size_of::<u32>() * gizmo_indices.len().max(1)) as u64,
356                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
357                mapped_at_creation: true,
358            });
359            gizmo_index_buffer
360                .slice(..)
361                .get_mapped_range_mut()
362                .copy_from_slice(bytemuck::cast_slice(&gizmo_indices));
363            gizmo_index_buffer.unmap();
364            let gizmo_uniform = crate::interaction::gizmo::GizmoUniform {
365                model: glam::Mat4::IDENTITY.to_cols_array_2d(),
366            };
367            let gizmo_uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
368                label: Some("vp_gizmo_uniform_buf"),
369                size: std::mem::size_of::<crate::interaction::gizmo::GizmoUniform>() as u64,
370                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
371                mapped_at_creation: true,
372            });
373            gizmo_uniform_buf
374                .slice(..)
375                .get_mapped_range_mut()
376                .copy_from_slice(bytemuck::cast_slice(&[gizmo_uniform]));
377            gizmo_uniform_buf.unmap();
378            let gizmo_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
379                label: Some("vp_gizmo_bind_group"),
380                layout: &self.resources.gizmo_bind_group_layout,
381                entries: &[wgpu::BindGroupEntry {
382                    binding: 0,
383                    resource: gizmo_uniform_buf.as_entire_binding(),
384                }],
385            });
386
387            // Per-viewport axes vertex buffer (2048 vertices = enough for all axes geometry).
388            let axes_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
389                label: Some("vp_axes_vertex_buf"),
390                size: (std::mem::size_of::<crate::widgets::axes_indicator::AxesVertex>() * 2048)
391                    as u64,
392                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
393                mapped_at_creation: false,
394            });
395
396            self.viewport_slots.push(ViewportSlot {
397                camera_buf,
398                clip_planes_buf,
399                clip_volume_buf,
400                shadow_info_buf,
401                grid_buf,
402                camera_bind_group,
403                grid_bind_group,
404                hdr: None,
405                outline_object_buffers: Vec::new(),
406                xray_object_buffers: Vec::new(),
407                constraint_line_buffers: Vec::new(),
408                cap_buffers: Vec::new(),
409                clip_plane_fill_buffers: Vec::new(),
410                clip_plane_line_buffers: Vec::new(),
411                axes_vertex_buffer,
412                axes_vertex_count: 0,
413                gizmo_uniform_buf,
414                gizmo_bind_group,
415                gizmo_vertex_buffer,
416                gizmo_index_buffer,
417                gizmo_index_count,
418            });
419        }
420    }
421
422    // -----------------------------------------------------------------------
423    // Multi-viewport public API (Phase 5)
424    // -----------------------------------------------------------------------
425
426    /// Create a new viewport slot and return its handle.
427    ///
428    /// The returned [`ViewportId`] is stable for the lifetime of the renderer.
429    /// Pass it to [`prepare_viewport`](Self::prepare_viewport),
430    /// [`paint_viewport`](Self::paint_viewport), and
431    /// [`render_viewport`](Self::render_viewport) each frame.
432    ///
433    /// Also set `CameraFrame::viewport_index` to `id.0` when building the
434    /// [`FrameData`] for this viewport:
435    /// ```rust,ignore
436    /// let id = renderer.create_viewport(&device);
437    /// let frame = FrameData {
438    ///     camera: CameraFrame::from_camera(&cam, size).with_viewport_index(id.0),
439    ///     ..Default::default()
440    /// };
441    /// ```
442    pub fn create_viewport(&mut self, device: &wgpu::Device) -> ViewportId {
443        let idx = self.viewport_slots.len();
444        self.ensure_viewport_slot(device, idx);
445        ViewportId(idx)
446    }
447
448    /// Release the heavy GPU texture memory (HDR targets, OIT, bloom, SSAO) held
449    /// by `id`.
450    ///
451    /// The slot index is not reclaimed — future calls with this `ViewportId` will
452    /// lazily recreate the texture resources as needed.  This is useful when a
453    /// viewport is hidden or minimised and you want to reduce VRAM pressure without
454    /// invalidating the handle.
455    pub fn destroy_viewport(&mut self, id: ViewportId) {
456        if let Some(slot) = self.viewport_slots.get_mut(id.0) {
457            slot.hdr = None;
458        }
459    }
460
461    /// Prepare shared scene data.  Call **once per frame**, before any
462    /// [`prepare_viewport`](Self::prepare_viewport) calls.
463    ///
464    /// `frame` provides the scene content (`frame.scene`) and the primary camera
465    /// used for shadow cascade framing (`frame.camera`).  In a multi-viewport
466    /// setup use any one viewport's `FrameData` here — typically the perspective
467    /// view — as the shadow framing reference.
468    ///
469    /// `scene_effects` carries the scene-global effects: lighting, environment
470    /// map, and compute filters.  Obtain it by constructing [`SceneEffects`]
471    /// directly or via [`EffectsFrame::split`].
472    pub fn prepare_scene(
473        &mut self,
474        device: &wgpu::Device,
475        queue: &wgpu::Queue,
476        frame: &FrameData,
477        scene_effects: &SceneEffects<'_>,
478    ) {
479        self.prepare_scene_internal(device, queue, frame, scene_effects);
480    }
481
482    /// Prepare per-viewport GPU state (camera, clip planes, overlays, axes).
483    ///
484    /// Call once per viewport per frame, **after** [`prepare_scene`](Self::prepare_scene).
485    ///
486    /// `id` must have been obtained from [`create_viewport`](Self::create_viewport).
487    /// `frame.camera.viewport_index` must equal `id.0`; use
488    /// [`CameraFrame::with_viewport_index`] when building the frame.
489    pub fn prepare_viewport(
490        &mut self,
491        device: &wgpu::Device,
492        queue: &wgpu::Queue,
493        id: ViewportId,
494        frame: &FrameData,
495    ) {
496        debug_assert_eq!(
497            frame.camera.viewport_index, id.0,
498            "frame.camera.viewport_index ({}) must equal the ViewportId ({}); \
499             use CameraFrame::with_viewport_index(id.0)",
500            frame.camera.viewport_index, id.0,
501        );
502        let (_, viewport_fx) = frame.effects.split();
503        self.prepare_viewport_internal(device, queue, frame, &viewport_fx);
504    }
505
506    /// Issue draw calls for `id` into a `'static` render pass (as provided by egui callbacks).
507    ///
508    /// This is the method to use from an egui/eframe `CallbackTrait::paint` implementation.
509    /// Call [`prepare_scene`](Self::prepare_scene) and [`prepare_viewport`](Self::prepare_viewport)
510    /// first (in `CallbackTrait::prepare`), then set the render pass viewport/scissor to confine
511    /// drawing to the correct quadrant, and call this method.
512    ///
513    /// For non-`'static` render passes (winit, iced, manual wgpu), use
514    /// [`paint_viewport_to`](Self::paint_viewport_to).
515    pub fn paint_viewport(
516        &self,
517        render_pass: &mut wgpu::RenderPass<'static>,
518        id: ViewportId,
519        frame: &FrameData,
520    ) {
521        let vp_idx = id.0;
522        let camera_bg = self.viewport_camera_bind_group(vp_idx);
523        let grid_bg = self.viewport_grid_bind_group(vp_idx);
524        let vp_slot = self.viewport_slots.get(vp_idx);
525        emit_draw_calls!(
526            &self.resources,
527            &mut *render_pass,
528            frame,
529            self.use_instancing,
530            &self.instanced_batches,
531            camera_bg,
532            grid_bg,
533            &self.compute_filter_results,
534            vp_slot
535        );
536        emit_scivis_draw_calls!(
537            &self.resources,
538            render_pass,
539            &self.point_cloud_gpu_data,
540            &self.glyph_gpu_data,
541            &self.polyline_gpu_data,
542            &self.volume_gpu_data,
543            &self.streamtube_gpu_data,
544            camera_bg
545        );
546    }
547
548    /// Issue draw calls for `id` into a render pass with any lifetime.
549    ///
550    /// Identical to [`paint_viewport`](Self::paint_viewport) but accepts a render pass with a
551    /// non-`'static` lifetime, making it usable from winit, iced, or raw wgpu where the encoder
552    /// creates its own render pass.
553    pub fn paint_viewport_to<'rp>(
554        &'rp self,
555        render_pass: &mut wgpu::RenderPass<'rp>,
556        id: ViewportId,
557        frame: &FrameData,
558    ) {
559        let vp_idx = id.0;
560        let camera_bg = self.viewport_camera_bind_group(vp_idx);
561        let grid_bg = self.viewport_grid_bind_group(vp_idx);
562        let vp_slot = self.viewport_slots.get(vp_idx);
563        emit_draw_calls!(
564            &self.resources,
565            &mut *render_pass,
566            frame,
567            self.use_instancing,
568            &self.instanced_batches,
569            camera_bg,
570            grid_bg,
571            &self.compute_filter_results,
572            vp_slot
573        );
574        emit_scivis_draw_calls!(
575            &self.resources,
576            render_pass,
577            &self.point_cloud_gpu_data,
578            &self.glyph_gpu_data,
579            &self.polyline_gpu_data,
580            &self.volume_gpu_data,
581            &self.streamtube_gpu_data,
582            camera_bg
583        );
584    }
585
586    /// Return a reference to the camera bind group for the given viewport slot.
587    ///
588    /// Falls back to `resources.camera_bind_group` if no per-viewport slot
589    /// exists (e.g. in single-viewport mode before the first prepare call).
590    fn viewport_camera_bind_group(&self, viewport_index: usize) -> &wgpu::BindGroup {
591        self.viewport_slots
592            .get(viewport_index)
593            .map(|slot| &slot.camera_bind_group)
594            .unwrap_or(&self.resources.camera_bind_group)
595    }
596
597    /// Return a reference to the grid bind group for the given viewport slot.
598    ///
599    /// Falls back to `resources.grid_bind_group` if no per-viewport slot exists.
600    fn viewport_grid_bind_group(&self, viewport_index: usize) -> &wgpu::BindGroup {
601        self.viewport_slots
602            .get(viewport_index)
603            .map(|slot| &slot.grid_bind_group)
604            .unwrap_or(&self.resources.grid_bind_group)
605    }
606
607    /// Ensure per-viewport HDR state exists for `viewport_index` at dimensions `w`×`h`.
608    ///
609    /// Calls `ensure_hdr_shared` once to initialise shared pipelines/BGLs/samplers, then
610    /// lazily creates or resizes the `ViewportHdrState` inside the slot. Idempotent: if the
611    /// slot already has HDR state at the correct size nothing is recreated.
612    pub(crate) fn ensure_viewport_hdr(
613        &mut self,
614        device: &wgpu::Device,
615        queue: &wgpu::Queue,
616        viewport_index: usize,
617        w: u32,
618        h: u32,
619        ssaa_factor: u32,
620    ) {
621        let format = self.resources.target_format;
622        // Ensure shared infrastructure (pipelines, BGLs, samplers) exists.
623        self.resources.ensure_hdr_shared(device, queue, format);
624        // Ensure the slot exists.
625        self.ensure_viewport_slot(device, viewport_index);
626        let slot = &mut self.viewport_slots[viewport_index];
627        // Create or resize the per-viewport HDR state.
628        let needs_create = match &slot.hdr {
629            None => true,
630            Some(h_state) => h_state.size != [w, h] || h_state.ssaa_factor != ssaa_factor,
631        };
632        if needs_create {
633            slot.hdr = Some(
634                self.resources
635                    .create_hdr_viewport_state(device, queue, format, w, h, ssaa_factor),
636            );
637        }
638    }
639}