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 instancing_state;
11use instancing_state::InstancingState;
12mod per_object_state;
13use per_object_state::PerObjectState;
14mod shadow_state;
15use shadow_state::ShadowState;
16mod paths;
17pub use paths::{OwnedPath, PassPath, PassView};
18mod picking;
19pub use picking::PickRectResult;
20mod point_shadow_pool;
21mod prepare;
22mod render;
23pub mod shader_hashes;
24mod shadow_debug_stats;
25mod shadows;
26pub mod stats;
27pub use shadow_debug_stats::ShadowDebugStats;
28
29#[cfg(test)]
30mod hidden_tests;
31#[cfg(test)]
32mod lod_instance_tests;
33
34pub use self::types::{
35    AnimTrack, AtlasViewerCorner, BorderMode, CameraFrame, ClipObject, ClipShape,
36    ComputeFilterItem, ComputeFilterKind, CylindricalFacing, DebugOutputMode, DebugQuantity,
37    DebugVis, DecalAnimation, DecalBlendMode, DecalItem, DecalProjection, EffectsFrame,
38    EmitterConfig, EnvironmentMap, FilterMode, ForceField, FrameData, GaussianSplatData,
39    GaussianSplatId, GaussianSplatItem, GlyphItem, GlyphSetRefItem, GlyphType,
40    GpuParticleSystemItem, GradientStop, GroundPlane, GroundPlaneMode, ImageAnchor, ImageSliceItem,
41    InteractionFrame, LabelAnchor, LabelItem, LerpAnim, LicOverlay, LightKind, LightSource,
42    LightingSettings, LineCap, LineJoin, LoadingBarAnchor, LoadingBarItem, MAX_POINT_SHADOW_LIGHTS,
43    MeshInstanceItem, NineSlice, OVERLAY_MAX_GRADIENT_STOPS, OverlayAnimation, OverlayAnimations,
44    OverlayEasing, OverlayFill, OverlayFrame, OverlayImageItem, OverlayPolylineItem,
45    OverlayRectItem, OverlayShape, OverlayShapeItem, OverlayTextureId, POINT_SHADOW_FACE_SIZE,
46    ParticleMeshAlign, PathTrack, PickId, PointCloudItem, PointCloudRefItem, PointRenderMode,
47    PointShadowMode, PolylineItem, PolylineRefItem, PostProcessSettings, RenderCamera, RepeatMode,
48    RibbonItem, RibbonRefItem, RulerItem, ScalarBarAnchor, ScalarBarItem, ScalarBarOrientation,
49    ScatterQuality, ScatterSettings, ScatterVolumeItem, SceneEffects, SceneFrame, SceneRenderItem,
50    ScreenImageItem, ShDegree, ShadowFilter, SliceAxis, SpawnShape, SpriteBlend,
51    SpriteInstanceSetRefItem, SpriteItem, SpriteLitParams, SpriteNormalMode, SpriteOrientation,
52    SpriteSetRefItem, SpriteSizeMode, StreamtubeItem, StreamtubeRefItem, SurfaceLICConfig,
53    SurfaceSubmission, TensorGlyphItem, TensorGlyphSetRefItem, TextureTransform, TileMode,
54    ToneMapping, TriangleDirection, TubeItem, TubeRefItem, VelocityDist, ViewportEffects,
55    ViewportFrame, VolumeItem, VolumeMeshItem, VolumeSurfaceSliceItem, VolumeTransparency,
56    aabb_wireframe_polyline, sphere_wireframe_polyline,
57};
58
59/// An opaque handle to a per-viewport GPU state slot.
60///
61/// Obtained from [`ViewportRenderer::create_viewport`] and passed to
62/// [`ViewportRenderer::prepare_viewport`], [`ViewportRenderer::paint_viewport`],
63/// and [`ViewportRenderer::render_viewport`].
64///
65/// The slot index is managed internally. To bind a `ViewportId` to a camera frame,
66/// use [`CameraFrame::with_viewport_id`]. Single-viewport applications that use
67/// the legacy [`ViewportRenderer::prepare`] / [`ViewportRenderer::paint`] API do
68/// not need this type.
69#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
70pub struct ViewportId(pub(crate) usize);
71
72use self::shadows::{compute_cascade_matrix, compute_cascade_splits};
73use self::types::{INSTANCING_THRESHOLD, InstancedBatch};
74use crate::resources::{
75    BatchMeta, CLIP_VOLUME_MAX, CameraUniform, ClipPlanesUniform, ClipVolumeEntry,
76    ClipVolumesUniform, GridUniform, InstanceAabb, InstanceData, LightsUniform, ObjectUniform,
77    OutlineEdgeUniform, OutlineObjectBuffers, OutlineUniform, PickInstance, ShadowAtlasUniform,
78    SingleLightUniform, SplatOutlineMaskUniform, ViewportGpuResources,
79};
80
81/// Per-viewport GPU state: uniform buffers and bind groups that differ per viewport.
82///
83/// Each viewport slot owns its own camera, clip planes, clip volume, shadow info,
84/// and grid buffers, plus the bind groups that reference them. Scene-global
85/// resources (lights, shadow atlas texture, IBL) are shared via the bind group
86/// pointing to buffers on `ViewportGpuResources`.
87pub(crate) struct ViewportSlot {
88    pub camera_buf: wgpu::Buffer,
89    pub clip_planes_buf: wgpu::Buffer,
90    pub clip_volume_buf: wgpu::Buffer,
91    pub shadow_info_buf: wgpu::Buffer,
92    pub grid_buf: wgpu::Buffer,
93    /// Camera bind group (group 0) referencing this slot's per-viewport buffers
94    /// plus shared scene-global resources.
95    pub camera_bind_group: wgpu::BindGroup,
96    /// Grid bind group (group 0 for grid pipeline) referencing this slot's grid buffer.
97    pub grid_bind_group: wgpu::BindGroup,
98    /// Per-viewport HDR post-process render targets.
99    ///
100    /// Created lazily on first HDR render call and resized when viewport dimensions change.
101    pub hdr: Option<crate::resources::ViewportHdrState>,
102    /// Per-fragment debug storage buffer (group 0 binding 12). Allocated at
103    /// `width * height * 16` bytes when debug_vis is active; None otherwise.
104    pub debug_frag_buf: Option<wgpu::Buffer>,
105    /// Viewport dimensions for which `debug_frag_buf` was allocated.
106    pub debug_frag_dims: (u32, u32),
107
108    // --- Per-viewport interaction state ---
109    /// Per-frame outline buffers for selected objects, rebuilt in prepare().
110    pub outline_object_buffers: Vec<OutlineObjectBuffers>,
111    /// Per-frame outline buffers for selected Gaussian splat sets, rebuilt in prepare().
112    pub splat_outline_buffers: Vec<crate::resources::SplatOutlineBuffers>,
113    /// Indices into `volume_gpu_data` for selected volumes, rebuilt in prepare().
114    pub volume_outline_indices: Vec<usize>,
115    /// Indices into `glyph_gpu_data` for selected glyph sets, rebuilt in prepare().
116    /// Each entry is (gpu_data_index, instance_filter): None draws all instances,
117    /// Some(indices) draws only those specific instance indices.
118    pub glyph_outline_indices: Vec<(usize, Option<Vec<u32>>)>,
119    /// Indices into `tensor_glyph_gpu_data` for selected tensor glyph sets, rebuilt in prepare().
120    pub tensor_glyph_outline_indices: Vec<(usize, Option<Vec<u32>>)>,
121    /// Indices into `sprite_gpu_data` for selected sprite sets, rebuilt in prepare().
122    pub sprite_outline_indices: Vec<(usize, Option<Vec<u32>>)>,
123    /// Per-frame inline quad outline buffers for selected image slices, rebuilt in prepare().
124    pub raw_geom_outline_buffers: Vec<crate::resources::RawGeomOutlineBuffers>,
125    /// Per-frame NDC rect outline buffers for selected screen images, rebuilt in prepare().
126    pub screen_rect_outline_buffers: Vec<crate::resources::ScreenRectOutlineBuffers>,
127    /// Indices into `implicit_gpu_data` for selected GPU implicit items, rebuilt in prepare().
128    pub implicit_outline_indices: Vec<usize>,
129    /// Per-frame outline data for selected GPU marching cubes jobs, rebuilt in prepare().
130    pub mc_outline_data: Vec<crate::resources::gpu_marching_cubes::McOutlineItem>,
131    /// Outline items for selected streamtubes (index into streamtube_gpu_data + mask bind group).
132    pub streamtube_outline_items: Vec<crate::resources::CurveMeshOutlineItem>,
133    /// Outline items for selected tubes.
134    pub tube_outline_items: Vec<crate::resources::CurveMeshOutlineItem>,
135    /// Outline items for selected ribbons.
136    pub ribbon_outline_items: Vec<crate::resources::CurveMeshOutlineItem>,
137    /// Indices into polyline_gpu_data for selected user polylines.
138    pub polyline_outline_indices: Vec<usize>,
139    /// Per-frame x-ray buffers for selected objects, rebuilt in prepare().
140    pub xray_object_buffers: Vec<(
141        crate::resources::mesh_store::MeshId,
142        wgpu::Buffer,
143        wgpu::BindGroup,
144    )>,
145    /// Per-frame constraint guide line buffers, rebuilt in prepare().
146    pub constraint_line_buffers: Vec<(
147        wgpu::Buffer,
148        wgpu::Buffer,
149        u32,
150        wgpu::Buffer,
151        wgpu::BindGroup,
152    )>,
153    /// Per-frame cap geometry buffers (section view cross-section fill), rebuilt in prepare().
154    pub cap_buffers: Vec<(
155        wgpu::Buffer,
156        wgpu::Buffer,
157        u32,
158        wgpu::Buffer,
159        wgpu::BindGroup,
160    )>,
161    /// Per-frame clip plane fill overlay buffers, rebuilt in prepare().
162    pub clip_plane_fill_buffers: Vec<(
163        wgpu::Buffer,
164        wgpu::Buffer,
165        u32,
166        wgpu::Buffer,
167        wgpu::BindGroup,
168    )>,
169    /// Per-frame clip plane line overlay buffers, rebuilt in prepare().
170    pub clip_plane_line_buffers: Vec<(
171        wgpu::Buffer,
172        wgpu::Buffer,
173        u32,
174        wgpu::Buffer,
175        wgpu::BindGroup,
176    )>,
177    /// Vertex buffer for axes indicator geometry (rebuilt each frame).
178    pub axes_vertex_buffer: wgpu::Buffer,
179    /// Number of vertices in the axes indicator buffer.
180    pub axes_vertex_count: u32,
181    /// Gizmo model-matrix uniform buffer.
182    pub gizmo_uniform_buf: wgpu::Buffer,
183    /// Gizmo bind group (group 1: model matrix uniform).
184    pub gizmo_bind_group: wgpu::BindGroup,
185    /// Gizmo vertex buffer.
186    pub gizmo_vertex_buffer: wgpu::Buffer,
187    /// Gizmo index buffer.
188    pub gizmo_index_buffer: wgpu::Buffer,
189    /// Number of indices in the current gizmo mesh.
190    pub gizmo_index_count: u32,
191
192    // --- Sub-object highlight (per-viewport, generation-cached) ---
193    /// Per-viewport dynamic resolution intermediate render target.
194    /// `None` when render_scale == 1.0 or not yet initialised.
195    pub dyn_res: Option<crate::resources::dyn_res::DynResTarget>,
196    /// Per-viewport intermediate render target for the HDR eframe callback path.
197    /// `None` until the first `prepare_hdr_callback` call for this viewport.
198    pub hdr_callback: Option<crate::resources::dyn_res::HdrCallbackTarget>,
199    /// Cached GPU data for sub-object highlight rendering.
200    /// `None` when no sub-object selection is active and no volumes are selected.
201    pub sub_highlight: Option<crate::resources::SubHighlightGpuData>,
202    /// Version of the last sub-selection snapshot that was uploaded.
203    /// `u64::MAX` forces a rebuild on the first frame.
204    pub sub_highlight_generation: u64,
205}
206
207impl ViewportSlot {
208    /// Draw the axes orientation indicator into an already-begun render pass.
209    ///
210    /// Shared by the LDR and HDR paths; both draw it last in screen space so it
211    /// sits on top. Does nothing when the indicator is disabled or empty.
212    pub(crate) fn draw_axes_indicator(
213        &self,
214        render_pass: &mut wgpu::RenderPass<'_>,
215        resources: &ViewportGpuResources,
216        show_axes_indicator: bool,
217    ) {
218        if show_axes_indicator && self.axes_vertex_count > 0 {
219            render_pass.set_pipeline(&resources.axes_pipeline);
220            render_pass.set_vertex_buffer(0, self.axes_vertex_buffer.slice(..));
221            render_pass.draw(0..self.axes_vertex_count, 0..1);
222        }
223    }
224}
225
226/// Retained pick state for one GPU implicit surface, built during `prepare()`.
227struct GpuImplicitPickItem {
228    id: u64,
229    primitives: Vec<crate::resources::ImplicitPrimitive>,
230    blend_mode: crate::resources::ImplicitBlendMode,
231    max_steps: u32,
232    step_scale: f32,
233    hit_threshold: f32,
234    max_distance: f32,
235}
236
237/// Retained pick state for one GPU marching cubes job, built during `prepare()`.
238struct GpuMcPickItem {
239    id: u64,
240    isovalue: f32,
241    volume_data: std::sync::Arc<crate::geometry::marching_cubes::VolumeData>,
242}
243
244/// Renderer wrapping all GPU resources and providing `prepare()` and `paint()` methods.
245/// Per-viewport scene-colour resolve sampled by the refractive sprite pass.
246///
247/// Lazily allocated on the first frame containing a refractive sprite; resized
248/// whenever the HDR target dimensions change. The bind group is rebuilt with
249/// the resolve when either changes.
250struct SpriteRefractionResolve {
251    texture: wgpu::Texture,
252    view: wgpu::TextureView,
253    size: [u32; 2],
254}
255
256/// GPU timestamp slot for the main opaque HDR scene pass.
257pub(crate) const GPU_TS_SCENE: u32 = 0;
258/// GPU timestamp slot for the directional shadow depth pass.
259pub(crate) const GPU_TS_SHADOW: u32 = 1;
260/// GPU timestamp slot for the OIT accumulation pass.
261pub(crate) const GPU_TS_OIT: u32 = 2;
262/// GPU timestamp slot for the tone-map / resolve pass.
263pub(crate) const GPU_TS_POST: u32 = 3;
264/// GPU timestamp slot for the main-camera GPU cull dispatch (the
265/// `cull_instances` + `write_indirect_args` compute passes). Only the main
266/// camera cull is timed; shadow-cascade and single-mesh culls are not.
267pub(crate) const GPU_TS_CULL: u32 = 4;
268/// Number of measured GPU passes; the query set holds `2 * GPU_TS_SLOTS` entries
269/// (a begin/end pair per slot).
270pub(crate) const GPU_TS_SLOTS: u32 = 5;
271
272/// Owns the GPU pipelines and per-frame state for rendering a scene. Call
273/// `prepare` once per frame to upload data, then `paint_to` (or `render`) to
274/// issue draw calls.
275pub struct ViewportRenderer {
276    resources: ViewportGpuResources,
277    /// State for the instanced (GPU-driven) mesh draw path.
278    instancing: InstancingState,
279    /// Registered item-type plugins keyed by
280    /// [`ItemTypePlugin::type_name`](crate::plugin_api::ItemTypePlugin::type_name).
281    /// `init_gpu` is invoked once on registration; per-frame `prepare` and
282    /// `paint` fire when a matching collection is on `SceneFrame`.
283    item_type_plugins:
284        std::collections::HashMap<&'static str, Box<dyn crate::plugin_api::ItemTypePlugin>>,
285    /// Monotonic frame counter passed to plugin contexts.
286    plugin_frame_index: u64,
287    /// Performance counters from the last frame.
288    last_stats: crate::renderer::stats::FrameStats,
289    /// Per-frame point cloud GPU data, rebuilt in prepare(), consumed in paint().
290    point_cloud_gpu_data: Vec<crate::resources::PointCloudGpuData>,
291    /// Per-frame glyph GPU data, rebuilt in prepare(), consumed in paint().
292    glyph_gpu_data: Vec<crate::resources::GlyphGpuData>,
293    /// Per-frame tensor glyph GPU data, rebuilt in prepare(), consumed in paint().
294    tensor_glyph_gpu_data: Vec<crate::resources::TensorGlyphGpuData>,
295    /// Per-frame polyline GPU data, rebuilt in prepare(), consumed in paint().
296    polyline_gpu_data: Vec<crate::resources::PolylineGpuData>,
297    /// Per-frame volume GPU data, rebuilt in prepare(), consumed in paint().
298    volume_gpu_data: Vec<crate::resources::VolumeGpuData>,
299    /// Per-frame streamtube GPU data, rebuilt in prepare(), consumed in paint().
300    streamtube_gpu_data: Vec<crate::resources::StreamtubeGpuData>,
301    /// Per-frame general tube GPU data, rebuilt in prepare(), consumed in paint().
302    tube_gpu_data: Vec<crate::resources::StreamtubeGpuData>,
303    /// Per-frame ribbon GPU data, rebuilt in prepare(), consumed in paint().
304    ribbon_gpu_data: Vec<crate::resources::StreamtubeGpuData>,
305    /// Indices into streamtube_gpu_data for selected streamtubes (set in prepare_scene, consumed in prepare_viewport).
306    streamtube_selected_gpu_indices: Vec<usize>,
307    /// Indices into tube_gpu_data for selected tubes (set in prepare_scene, consumed in prepare_viewport).
308    tube_selected_gpu_indices: Vec<usize>,
309    /// Indices into ribbon_gpu_data for selected ribbons (set in prepare_scene, consumed in prepare_viewport).
310    ribbon_selected_gpu_indices: Vec<usize>,
311    /// Indices into polyline_gpu_data for selected user polylines (set in prepare_scene, consumed in prepare_viewport).
312    polyline_selected_gpu_indices: Vec<usize>,
313    /// Per-frame image slice GPU data, rebuilt in prepare(), consumed in paint().
314    image_slice_gpu_data: Vec<crate::resources::ImageSliceGpuData>,
315    /// Per-frame volume surface slice GPU data, rebuilt in prepare(), consumed in paint().
316    volume_surface_slice_gpu_data: Vec<crate::resources::VolumeSurfaceSliceGpuData>,
317    /// Per-frame Surface LIC GPU data, rebuilt in prepare(), consumed in paint().
318    lic_gpu_data: Vec<crate::resources::LicSurfaceGpuData>,
319    /// Per-frame GPU implicit surface data, rebuilt in prepare(), consumed in paint().
320    implicit_gpu_data: Vec<crate::resources::implicit::ImplicitGpuItem>,
321    /// Per-frame decal GPU data, rebuilt in prepare(), consumed in paint() (D1).
322    decal_gpu_data: Vec<crate::resources::decal::DecalGpuItem>,
323    /// Per-frame decal exclude GPU data, rebuilt in prepare(), consumed in paint() (D5).
324    decal_exclude_items: Vec<crate::resources::decal::DecalExcludeGpuItem>,
325    /// Per-frame GPU marching cubes render data, rebuilt in prepare(), consumed in paint().
326    mc_gpu_data: Vec<crate::resources::gpu_marching_cubes::McFrameData>,
327    /// Per-frame sprite GPU data, rebuilt in prepare(), consumed in paint().
328    sprite_gpu_data: Vec<crate::resources::SpriteGpuData>,
329    /// Per-frame mesh-instance batches, rebuilt in prepare(), consumed in paint().
330    mesh_instance_gpu_data: Vec<crate::resources::MeshInstanceGpuData>,
331    /// Per-frame GPU particle systems, dispatched in prepare(), consumed in paint().
332    particle_gpu_data: Vec<crate::resources::gpu_particles::ParticleFrameData>,
333    /// Scene-colour resolve textures for the refractive sprite pass, indexed
334    /// alongside `viewport_slots`. Lazily allocated when the first refractive
335    /// sprite appears for a viewport.
336    sprite_refraction_resolves: Vec<Option<SpriteRefractionResolve>>,
337    /// Per-frame Gaussian splat draw data, rebuilt in prepare_viewport_internal(), consumed in paint().
338    gaussian_splat_draw_data: Vec<crate::resources::GaussianSplatDrawData>,
339    /// Per-frame screen-image GPU data, rebuilt in prepare(), consumed in paint().
340    screen_image_gpu_data: Vec<crate::resources::ScreenImageGpuData>,
341    /// Per-frame overlay image GPU data, rebuilt in prepare(), consumed in paint().
342    overlay_image_gpu_data: Vec<crate::resources::ScreenImageGpuData>,
343    /// Per-frame overlay label GPU data, rebuilt in prepare(), consumed in paint().
344    label_gpu_data: Option<crate::resources::LabelGpuData>,
345    /// Per-frame scalar bar GPU data, rebuilt in prepare(), consumed in paint().
346    scalar_bar_gpu_data: Option<crate::resources::LabelGpuData>,
347    /// Per-frame ruler GPU data, rebuilt in prepare(), consumed in paint().
348    ruler_gpu_data: Option<crate::resources::LabelGpuData>,
349    /// Per-frame loading bar GPU data, rebuilt in prepare(), consumed in paint().
350    loading_bar_gpu_data: Option<crate::resources::LabelGpuData>,
351    /// Per-frame overlay rect GPU data, rebuilt in prepare(), consumed in paint().
352    overlay_rect_gpu_data: Option<crate::resources::LabelGpuData>,
353    /// Per-frame SDF overlay shape GPU data, rebuilt in prepare(), consumed in paint().
354    overlay_shape_gpu_data: Option<crate::resources::OverlayShapeGpuData>,
355    /// Cached GPU textures for the backdrop blur effect (frosted glass).
356    /// Recreated when the viewport size changes.
357    backdrop_blur_state: Option<crate::resources::BackdropBlurState>,
358    /// Per-viewport GPU state slots.
359    ///
360    /// Indexed by `FrameData::camera.viewport_index`. Each slot owns independent
361    /// uniform buffers and bind groups for camera, clip planes, clip volume,
362    /// shadow info, and grid. Slots are grown lazily in `prepare` via
363    /// `ensure_viewport_slot`. There are at most 4 in the current UI.
364    viewport_slots: Vec<ViewportSlot>,
365    /// GPU compute filter results from the last `prepare()` call.
366    ///
367    /// Each entry contains a compacted index buffer + count for one filtered mesh.
368    /// Consumed during `paint()` to override the mesh's default index buffer.
369    /// Cleared and rebuilt each frame.
370    compute_filter_results: Vec<crate::resources::ComputeFilterResult>,
371    /// State for the non-instanced (per-object) mesh draw path.
372    mesh_uniforms: PerObjectState,
373    /// Cached shadow state carried across frames.
374    shadow: ShadowState,
375    /// Current runtime mode controlling internal default behavior.
376    runtime_mode: crate::renderer::stats::RuntimeMode,
377    /// Optional cap on how much main-thread time `prepare` is allowed to
378    /// spend running apply closures for completed upload jobs.
379    ///
380    /// `None` means unbounded (apply work runs to completion in one
381    /// frame). `Some(d)` spreads the cost across frames so heavy
382    /// completions do not produce one fat frame; the deferred applies
383    /// run on the next call to `prepare`.
384    upload_budget: Option<std::time::Duration>,
385    /// Active performance policy: target FPS, render scale bounds, and permitted reductions.
386    performance_policy: crate::renderer::stats::PerformancePolicy,
387    /// Current render scale tracked by the adaptation controller (or set manually).
388    ///
389    /// Clamped to `[policy.min_render_scale, policy.max_render_scale]`.
390    /// Reported in `FrameStats::render_scale` each frame.
391    current_render_scale: f32,
392    /// Instant the renderer was constructed. Used as the t=0 reference for
393    /// per-frame animated effects (e.g. `ScatterVolume::noise` time scrolling).
394    start_instant: std::time::Instant,
395    /// Instant recorded at the start of the most recent `prepare()` call.
396    /// Used to compute `total_frame_ms` on the following frame.
397    last_prepare_instant: Option<std::time::Instant>,
398    /// Frame counter incremented each `prepare()` call. Used for picking throttle in Playback mode.
399    frame_counter: u64,
400    /// Current LOD level per item, keyed by pick id, carried across frames so
401    /// level switches use hysteresis. Items without a pick id are not tracked
402    /// here and resolve fresh each frame. Pruned to the items seen each frame.
403    lod_levels: std::collections::HashMap<u64, usize>,
404    /// Current LOD level per mesh instance, keyed by `(item pick id, instance
405    /// index)`. Same role as `lod_levels` but for `MeshInstanceItem`, where each
406    /// instance picks its own level. Instances in items without a pick id are
407    /// not tracked. Pruned to the instances seen each frame.
408    mesh_instance_lod_levels: std::collections::HashMap<(u64, u32), usize>,
409    /// Surface items from the last `prepare()` call, retained for `pick()` dispatch.
410    pick_scene_items: Vec<SceneRenderItem>,
411    /// Point cloud items from the last `prepare()` call, retained for `pick()` dispatch.
412    pick_point_cloud_items: Vec<PointCloudItem>,
413    /// Gaussian splat items from the last `prepare()` call, retained for `pick()` dispatch.
414    pick_splat_items: Vec<GaussianSplatItem>,
415    /// Volume items from the last `prepare()` call, retained for `pick()` dispatch.
416    pick_volume_items: Vec<VolumeItem>,
417    /// Scatter volume items from the last `prepare()` call, retained for `pick()` dispatch.
418    pick_scatter_volume_items: Vec<crate::renderer::types::ScatterVolumeItem>,
419    /// Volumes packed into the GPU storage buffer this frame
420    /// (volume, density_multiplier, flag bits). Stored so `render_viewport`
421    /// can re-upload as needed without re-walking the scene frame.
422    pub(crate) prepared_scatter_volumes:
423        Vec<(crate::scene::scatter_volume::ScatterVolume, f32, u32)>,
424    /// Subset of the prepared scatter volumes that carry `RefractionParams`.
425    /// Cleared and refilled each frame by `prepare_viewport`. The refraction
426    /// pass walks this list; an empty list skips the pass entirely.
427    pub(crate) prepared_refraction_volumes: Vec<(crate::scene::scatter_volume::ScatterVolume, f32)>,
428    /// Per-viewport scatter intermediates and temporal history. Indexed by
429    /// `vp_idx`. Grown lazily inside the scatter pass; each entry is
430    /// reallocated when the requested scatter target size or downsample mode
431    /// changes.
432    pub(crate) scatter_viewport_states: Vec<Option<crate::resources::ScatterViewportState>>,
433    /// Opaque volume mesh items from the last `prepare()` call, retained for cell-level `pick()` dispatch.
434    pick_volume_mesh_items: Vec<VolumeMeshItem>,
435    /// Polyline items from the last `prepare()` call, retained for `pick()` dispatch.
436    pick_polyline_items: Vec<PolylineItem>,
437    /// Glyph items from the last `prepare()` call, retained for `pick()` dispatch.
438    pick_glyph_items: Vec<GlyphItem>,
439    /// Tensor glyph items from the last `prepare()` call, retained for `pick()` dispatch.
440    pick_tensor_glyph_items: Vec<TensorGlyphItem>,
441    /// Sprite items from the last `prepare()` call, retained for `pick()` dispatch.
442    pick_sprite_items: Vec<SpriteItem>,
443    /// Streamtube items from the last `prepare()` call, retained for `pick()` dispatch.
444    pick_streamtube_items: Vec<StreamtubeItem>,
445    /// Tube items from the last `prepare()` call, retained for `pick()` dispatch.
446    pick_tube_items: Vec<TubeItem>,
447    /// Ribbon items from the last `prepare()` call, retained for `pick()` dispatch.
448    pick_ribbon_items: Vec<RibbonItem>,
449    /// Image slice items from the last `prepare()` call, retained for `pick()` dispatch.
450    pick_image_slice_items: Vec<ImageSliceItem>,
451    /// Volume surface slice items from the last `prepare()` call, retained for `pick()` dispatch.
452    pick_volume_surface_slice_items: Vec<VolumeSurfaceSliceItem>,
453    /// Screen image items from the last `prepare()` call, retained for `pick()` dispatch.
454    pick_screen_image_items: Vec<ScreenImageItem>,
455    /// GPU implicit surface items from the last `prepare()` call, retained for `pick()` dispatch.
456    pick_implicit_items: Vec<GpuImplicitPickItem>,
457    /// GPU marching cubes jobs from the last `prepare()` call, retained for `pick()` dispatch.
458    pick_mc_items: Vec<GpuMcPickItem>,
459    /// When `false`, `prepare()` skips populating the CPU pick caches above, so
460    /// scenes that never call `pick()`/`pick_rect()` avoid a per-frame deep copy
461    /// of all inline geometry. Enable with `set_cpu_pick_cache(true)`.
462    cpu_pick_cache_enabled: bool,
463
464    // --- GPU timestamp queries ---
465    /// Timestamp query set with `2 * GPU_TS_SLOTS` entries: a begin/end pair per
466    /// measured pass (see the `GPU_TS_*` slot constants). `None` when
467    /// `TIMESTAMP_QUERY` is unavailable or not yet initialized.
468    ts_query_set: Option<wgpu::QuerySet>,
469    /// Resolve buffer: `2 * GPU_TS_SLOTS` x u64, GPU-only (`QUERY_RESOLVE | COPY_SRC`).
470    ts_resolve_buf: Option<wgpu::Buffer>,
471    /// Staging buffer: `2 * GPU_TS_SLOTS` x u64, CPU-readable (`COPY_DST | MAP_READ`).
472    ts_staging_buf: Option<wgpu::Buffer>,
473    /// Bitmask of `GPU_TS_*` slots whose timestamps were written this frame.
474    /// Passes are conditional, so unwritten slots hold stale/undefined query
475    /// data; only slots set here are read back. Reset at the start of each frame.
476    ///
477    /// Atomic so a pass method can set its bit through `&self` without colliding
478    /// with the immutable viewport-slot borrows live during pass encoding (and
479    /// to keep `ViewportRenderer: Sync`).
480    ts_written_mask: std::sync::atomic::AtomicU32,
481    /// Snapshot of `ts_written_mask` taken when the queries are resolved, carried
482    /// alongside the one-frame-delayed readback so the reader knows which slots
483    /// are valid.
484    ts_pending_mask: u32,
485    /// Nanoseconds per GPU timestamp tick, from `queue.get_timestamp_period()`.
486    ts_period: f32,
487    /// True when the staging buffer holds resolved timestamps that have not yet
488    /// been mapped for readback.
489    ts_data_ready: bool,
490    /// True when a map of the timestamp staging buffer is in flight. The render
491    /// path skips the resolve/copy while this is set so the single staging
492    /// buffer is not overwritten before `prepare()` has read it.
493    ts_map_inflight: bool,
494    /// In-flight timestamp map status, set from the map callback: 0 = pending,
495    /// 1 = mapped, 2 = failed. An `Arc<AtomicU8>` rather than an mpsc channel so
496    /// `ViewportRenderer` stays `Sync` (mpsc receivers are not).
497    ts_map_status: std::sync::Arc<std::sync::atomic::AtomicU8>,
498
499    /// Per-phase CPU timings accumulated during the current `prepare()` call,
500    /// copied into `FrameStats::prepare_breakdown` at the end of the frame.
501    prepare_breakdown: crate::renderer::stats::PrepareBreakdown,
502
503    // --- Per-pass degradation state ---
504    /// Tiered degradation ladder position (0 = none, 1 = shadows, 2 = volumes, 3 = effects).
505    /// Advanced one step per over-budget frame once render scale hits minimum;
506    /// reversed one step per comfortably-under-budget frame.
507    degradation_tier: u8,
508    /// Whether the shadow pass was skipped this frame due to budget pressure.
509    /// Computed once per frame at the top of prepare() and used by both
510    /// prepare_scene_internal and reported in FrameStats.
511    degradation_shadows_skipped: bool,
512    /// Whether volume raymarch step size was doubled this frame due to budget pressure.
513    degradation_volume_quality_reduced: bool,
514    /// Whether SSAO, contact shadows, and bloom were skipped this frame.
515    /// Set in prepare(); read by the render path.
516    degradation_effects_throttled: bool,
517
518    /// Lights dropped by the CPU frustum cull on the most recent frame.
519    /// Surfaced through the cluster debug overlay when enabled.
520    pub(crate) last_frustum_culled_lights: u32,
521    /// Most recent cluster build readback. Populated when a frame's
522    /// `ViewportFrame::cluster_stats_request` was true.
523    pub(crate) last_cluster_stats: Option<crate::resources::clustered::ClusterStats>,
524}
525
526impl ViewportRenderer {
527    /// Create a new renderer with default settings (no MSAA).
528    /// Call once at application startup.
529    pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
530        Self::with_sample_count(device, target_format, 1)
531    }
532
533    /// Create a new renderer with the specified MSAA sample count (1, 2, or 4).
534    ///
535    /// When using MSAA (sample_count > 1), the caller must create multisampled
536    /// colour and depth textures and use them as render pass attachments with the
537    /// final surface texture as the resolve target.
538    pub fn with_sample_count(
539        device: &wgpu::Device,
540        target_format: wgpu::TextureFormat,
541        sample_count: u32,
542    ) -> Self {
543        Self::with_sample_count_and_cache(device, target_format, sample_count, None)
544    }
545
546    /// Create a renderer, seeding the GPU pipeline cache from previously saved
547    /// data so shader compilation can be skipped on later launches.
548    ///
549    /// Pass the bytes returned by an earlier [`pipeline_cache_data`](Self::pipeline_cache_data)
550    /// call, or `None` on first run. The cache only takes effect when the device
551    /// was created with `Features::PIPELINE_CACHE`; otherwise the data is ignored
552    /// and this matches [`new`](Self::new).
553    pub fn new_with_pipeline_cache(
554        device: &wgpu::Device,
555        target_format: wgpu::TextureFormat,
556        pipeline_cache_data: Option<&[u8]>,
557    ) -> Self {
558        Self::with_sample_count_and_cache(device, target_format, 1, pipeline_cache_data)
559    }
560
561    /// Returns the current contents of the GPU pipeline cache, suitable for
562    /// persisting and feeding back into [`new_with_pipeline_cache`](Self::new_with_pipeline_cache)
563    /// on the next launch. `None` when the device lacks `Features::PIPELINE_CACHE`.
564    pub fn pipeline_cache_data(&self) -> Option<Vec<u8>> {
565        self.resources.pipeline_cache.as_ref()?.get_data()
566    }
567
568    /// Like [`with_sample_count`](Self::with_sample_count) with an MSAA count and
569    /// an optional saved pipeline cache.
570    pub fn with_sample_count_and_cache(
571        device: &wgpu::Device,
572        target_format: wgpu::TextureFormat,
573        sample_count: u32,
574        pipeline_cache_data: Option<&[u8]>,
575    ) -> Self {
576        let gpu_culling_supported = device
577            .features()
578            .contains(wgpu::Features::INDIRECT_FIRST_INSTANCE);
579        Self {
580            resources: ViewportGpuResources::new_with_cache(
581                device,
582                target_format,
583                sample_count,
584                pipeline_cache_data,
585            ),
586            instancing: InstancingState::new(gpu_culling_supported),
587            item_type_plugins: std::collections::HashMap::new(),
588            plugin_frame_index: 0,
589            last_stats: crate::renderer::stats::FrameStats::default(),
590            prepare_breakdown: crate::renderer::stats::PrepareBreakdown::default(),
591            point_cloud_gpu_data: Vec::new(),
592            glyph_gpu_data: Vec::new(),
593            tensor_glyph_gpu_data: Vec::new(),
594            polyline_gpu_data: Vec::new(),
595            volume_gpu_data: Vec::new(),
596            streamtube_gpu_data: Vec::new(),
597            tube_gpu_data: Vec::new(),
598            ribbon_gpu_data: Vec::new(),
599            streamtube_selected_gpu_indices: Vec::new(),
600            tube_selected_gpu_indices: Vec::new(),
601            ribbon_selected_gpu_indices: Vec::new(),
602            polyline_selected_gpu_indices: Vec::new(),
603            image_slice_gpu_data: Vec::new(),
604            volume_surface_slice_gpu_data: Vec::new(),
605            sprite_gpu_data: Vec::new(),
606            mesh_instance_gpu_data: Vec::new(),
607            particle_gpu_data: Vec::new(),
608            sprite_refraction_resolves: Vec::new(),
609            gaussian_splat_draw_data: Vec::new(),
610            lic_gpu_data: Vec::new(),
611            implicit_gpu_data: Vec::new(),
612            decal_gpu_data: Vec::new(),
613            decal_exclude_items: Vec::new(),
614            mc_gpu_data: Vec::new(),
615            screen_image_gpu_data: Vec::new(),
616            overlay_image_gpu_data: Vec::new(),
617            label_gpu_data: None,
618            scalar_bar_gpu_data: None,
619            ruler_gpu_data: None,
620            loading_bar_gpu_data: None,
621            overlay_rect_gpu_data: None,
622            overlay_shape_gpu_data: None,
623            backdrop_blur_state: None,
624            viewport_slots: Vec::new(),
625            compute_filter_results: Vec::new(),
626            mesh_uniforms: PerObjectState::new(),
627            shadow: ShadowState::new(),
628            runtime_mode: crate::renderer::stats::RuntimeMode::Interactive,
629            performance_policy: crate::renderer::stats::PerformancePolicy::default(),
630            upload_budget: None,
631            current_render_scale: 1.0,
632            start_instant: std::time::Instant::now(),
633            last_prepare_instant: None,
634            frame_counter: 0,
635            lod_levels: std::collections::HashMap::new(),
636            mesh_instance_lod_levels: std::collections::HashMap::new(),
637            pick_scene_items: Vec::new(),
638            pick_point_cloud_items: Vec::new(),
639            pick_splat_items: Vec::new(),
640            pick_volume_items: Vec::new(),
641            pick_scatter_volume_items: Vec::new(),
642            prepared_scatter_volumes: Vec::new(),
643            prepared_refraction_volumes: Vec::new(),
644            scatter_viewport_states: Vec::new(),
645            pick_volume_mesh_items: Vec::new(),
646            pick_polyline_items: Vec::new(),
647            pick_glyph_items: Vec::new(),
648            pick_tensor_glyph_items: Vec::new(),
649            pick_sprite_items: Vec::new(),
650            pick_streamtube_items: Vec::new(),
651            pick_tube_items: Vec::new(),
652            pick_ribbon_items: Vec::new(),
653            pick_image_slice_items: Vec::new(),
654            pick_volume_surface_slice_items: Vec::new(),
655            pick_screen_image_items: Vec::new(),
656            pick_implicit_items: Vec::new(),
657            pick_mc_items: Vec::new(),
658            cpu_pick_cache_enabled: false,
659            ts_query_set: None,
660            ts_resolve_buf: None,
661            ts_staging_buf: None,
662            ts_period: 1.0,
663            ts_data_ready: false,
664            ts_map_inflight: false,
665            ts_map_status: std::sync::Arc::new(std::sync::atomic::AtomicU8::new(0)),
666            ts_written_mask: std::sync::atomic::AtomicU32::new(0),
667            ts_pending_mask: 0,
668            degradation_tier: 0,
669            degradation_shadows_skipped: false,
670            degradation_volume_quality_reduced: false,
671            degradation_effects_throttled: false,
672            last_frustum_culled_lights: 0,
673            last_cluster_stats: None,
674        }
675    }
676
677    /// Access the underlying GPU resources (e.g. for mesh uploads).
678    pub fn resources(&self) -> &ViewportGpuResources {
679        &self.resources
680    }
681
682    /// Performance counters from the last completed frame.
683    pub fn last_frame_stats(&self) -> crate::renderer::stats::FrameStats {
684        self.last_stats
685    }
686
687    /// Diagnostics from the cluster build pass on the most recent frame that
688    /// requested them (`ViewportFrame::cluster_stats_request`). Returns
689    /// `None` until a request has been served.
690    pub fn cluster_stats(&self) -> Option<crate::resources::clustered::ClusterStats> {
691        self.last_cluster_stats
692    }
693
694    /// Disable GPU-driven culling, reverting to the direct draw path.
695    ///
696    /// Has no effect when the device does not support `INDIRECT_FIRST_INSTANCE`
697    /// (culling is already disabled on those devices).
698    pub fn disable_gpu_driven_culling(&mut self) {
699        self.instancing.gpu_culling_enabled = false;
700    }
701
702    /// Force a full instance buffer upload on the next frame.
703    ///
704    /// Normally the renderer skips GPU writes for instanced batches whose data
705    /// has not changed since the last upload. Call this when you have mutated
706    /// batch-relevant state through a path the renderer cannot observe (for
707    /// example, directly modifying GPU buffer contents or scene items after
708    /// `collect_render_items` runs). The flag is consumed once and resets
709    /// automatically after the next `prepare` call.
710    pub fn force_dirty(&mut self) {
711        self.instancing.force_full_upload = true;
712        // Also invalidate the generation cache so the next prepare is guaranteed
713        // to enter the rebuild path even if the scene generation is unchanged.
714        self.instancing.last_scene_generation = u64::MAX;
715    }
716
717    /// Re-enable GPU-driven culling after a call to `disable_gpu_driven_culling`.
718    ///
719    /// Has no effect when the device does not support `INDIRECT_FIRST_INSTANCE`.
720    pub fn enable_gpu_driven_culling(&mut self) {
721        if self.instancing.gpu_culling_supported {
722            self.instancing.gpu_culling_enabled = true;
723        }
724    }
725
726    /// Cap the per-frame cost of running upload-job apply closures.
727    ///
728    /// `None` is the default and matches the historical behaviour:
729    /// `prepare` drains every completed upload's apply step in one
730    /// shot. `Some(d)` switches `prepare` over to
731    /// `process_uploads_with_budget` so applies that overflow the
732    /// budget spill to the next frame. Useful when a stress load lands
733    /// many heavy completions on the same frame and the bunched apply
734    /// work shows up as one fat frame at the end of the load.
735    pub fn set_upload_budget(&mut self, budget: Option<std::time::Duration>) {
736        self.upload_budget = budget;
737    }
738
739    /// Currently configured upload budget. See `set_upload_budget`.
740    pub fn upload_budget(&self) -> Option<std::time::Duration> {
741        self.upload_budget
742    }
743
744    /// Set the runtime mode controlling internal default behavior.
745    ///
746    /// - [`RuntimeMode::Interactive`]: full picking rate, full quality (default).
747    /// - [`RuntimeMode::Playback`]: picking throttled to reduce CPU overhead during animation.
748    /// - [`RuntimeMode::Paused`]: full picking rate, full quality.
749    /// - [`RuntimeMode::Capture`]: full quality, intended for screenshot/export workflows.
750    pub fn set_runtime_mode(&mut self, mode: crate::renderer::stats::RuntimeMode) {
751        self.runtime_mode = mode;
752    }
753
754    /// Return the current runtime mode.
755    pub fn runtime_mode(&self) -> crate::renderer::stats::RuntimeMode {
756        self.runtime_mode
757    }
758
759    /// Enable or disable the CPU pick cache.
760    ///
761    /// When enabled, `prepare()` retains a copy of the frame's pickable items so
762    /// `pick()` and `pick_rect()` can run later (e.g. on a mouse click) without the
763    /// scene data. This copies all inline point/glyph/curve geometry each frame, so it
764    /// is disabled by default: turn it on only when using the CPU `pick()`/`pick_rect()`
765    /// path. The GPU path (`pick_scene_gpu`) and the renderer-free
766    /// `interaction::picking` functions do not need it.
767    pub fn set_cpu_pick_cache(&mut self, enabled: bool) {
768        if !enabled && self.cpu_pick_cache_enabled {
769            self.clear_pick_cache();
770        }
771        self.cpu_pick_cache_enabled = enabled;
772    }
773
774    /// Whether the CPU pick cache is enabled. See `set_cpu_pick_cache`.
775    pub fn cpu_pick_cache(&self) -> bool {
776        self.cpu_pick_cache_enabled
777    }
778
779    /// Set the performance policy controlling target FPS, render scale bounds,
780    /// and permitted quality reductions.
781    ///
782    /// The internal adaptation controller activates when
783    /// `policy.allow_dynamic_resolution` is `true` and `policy.target_fps` is
784    /// `Some`. It adjusts `render_scale` within `[min_render_scale,
785    /// max_render_scale]` each frame based on `total_frame_ms`.
786    pub fn set_performance_policy(&mut self, policy: crate::renderer::stats::PerformancePolicy) {
787        self.performance_policy = policy;
788        // Clamp current scale into the new bounds immediately.
789        self.current_render_scale = self
790            .current_render_scale
791            .clamp(policy.min_render_scale, policy.max_render_scale);
792    }
793
794    /// Return the active performance policy.
795    pub fn performance_policy(&self) -> crate::renderer::stats::PerformancePolicy {
796        self.performance_policy
797    }
798
799    /// Manually set the render scale.
800    ///
801    /// Effective when `performance_policy.allow_dynamic_resolution` is `false`.
802    /// When dynamic resolution is enabled the adaptation controller overrides
803    /// this value each frame.
804    ///
805    /// The value is clamped to `[policy.min_render_scale, policy.max_render_scale]`.
806    ///
807    /// Works on both the LDR and HDR render paths. On the HDR path, the scene,
808    /// bloom, SSAO, tone-map, and FXAA all run at the scaled resolution; the
809    /// result is upscale-blitted to native resolution before overlays and grid.
810    pub fn set_render_scale(&mut self, scale: f32) {
811        self.current_render_scale = scale.clamp(
812            self.performance_policy.min_render_scale,
813            self.performance_policy.max_render_scale,
814        );
815    }
816
817    /// Set the target frame rate used to compute [`FrameStats::missed_budget`].
818    ///
819    /// Convenience wrapper that updates `performance_policy.target_fps`.
820    pub fn set_target_fps(&mut self, fps: Option<f32>) {
821        self.performance_policy.target_fps = fps;
822    }
823
824    /// Mutable access to the underlying GPU resources (e.g. for mesh uploads).
825    pub fn resources_mut(&mut self) -> &mut ViewportGpuResources {
826        &mut self.resources
827    }
828
829    /// Returns true when the current frame is rendered via the instanced draw path.
830    ///
831    /// When true, edits to mesh.wgsl shadow sampling code have no effect - the active
832    /// shader is mesh_instanced.wgsl. Check this before testing shader changes.
833    pub fn is_using_instanced_path(&self) -> bool {
834        self.instancing.use_instancing
835    }
836
837    /// Returns the number of instanced batches prepared for the current frame.
838    ///
839    /// Zero when using the non-instanced path. Each batch corresponds to a distinct
840    /// (MeshId, material) combination in the scene.
841    pub fn instanced_batch_count(&self) -> usize {
842        self.instancing.batches.len()
843    }
844
845    /// Run the GPU-driven cull compute against a plugin's
846    /// [`CullSubmission`](crate::plugin_api::CullSubmission).
847    ///
848    /// Encodes two compute passes into `encoder`:
849    /// 1. one thread per instance, tests AABB against `frustum`, claims a
850    ///    visibility slot via atomic add;
851    /// 2. one thread per batch, writes a `DrawIndexedIndirect` entry into
852    ///    `sub.indirect_out` with the final visible count and zeroes the
853    ///    counter for the next call.
854    ///
855    /// After the encoder runs, draw each batch with
856    /// `pass.draw_indexed_indirect(sub.indirect_out, batch_idx * 20)` using
857    /// `sub.visible_out` as the per-instance lookup buffer.
858    ///
859    /// The cull pipeline is created lazily on the first call. Returns
860    /// without dispatching if the device does not support
861    /// `INDIRECT_FIRST_INSTANCE` (call
862    /// [`is_gpu_culling_supported`](Self::is_gpu_culling_supported) first).
863    pub fn submit_cull(
864        &mut self,
865        device: &wgpu::Device,
866        queue: &wgpu::Queue,
867        encoder: &mut wgpu::CommandEncoder,
868        frustum: &crate::camera::frustum::Frustum,
869        sub: &crate::plugin_api::CullSubmission<'_>,
870    ) {
871        if !self.instancing.gpu_culling_supported {
872            return;
873        }
874        if self.instancing.cull_resources.is_none() {
875            self.instancing.cull_resources =
876                Some(crate::renderer::indirect::CullResources::new(device));
877        }
878        let cull = self.instancing.cull_resources.as_ref().unwrap();
879        cull.dispatch(encoder, device, queue, frustum, None, sub, None);
880    }
881
882    /// Same as [`submit_cull`](Self::submit_cull) for one shadow cascade.
883    ///
884    /// Uploads the frustum to the cascade slot (so a single frame can submit
885    /// the main pass plus every cascade without overwriting an in-flight
886    /// upload) and forces the cull shader's shadow flag so
887    /// `InstanceAabb::cast_shadows = 0` entries are skipped.
888    ///
889    /// `cascade_idx` must be in `0..4`; values outside that range panic in
890    /// debug builds and clamp to 3 in release.
891    pub fn submit_cull_shadow(
892        &mut self,
893        device: &wgpu::Device,
894        queue: &wgpu::Queue,
895        encoder: &mut wgpu::CommandEncoder,
896        cascade_idx: usize,
897        cascade_frustum: &crate::camera::frustum::Frustum,
898        sub: &crate::plugin_api::CullSubmission<'_>,
899    ) {
900        if !self.instancing.gpu_culling_supported {
901            return;
902        }
903        debug_assert!(cascade_idx < 4, "cascade_idx must be in 0..4");
904        let cascade_idx = cascade_idx.min(3);
905        if self.instancing.cull_resources.is_none() {
906            self.instancing.cull_resources =
907                Some(crate::renderer::indirect::CullResources::new(device));
908        }
909        let cull = self.instancing.cull_resources.as_ref().unwrap();
910        cull.dispatch(
911            encoder,
912            device,
913            queue,
914            cascade_frustum,
915            Some(cascade_idx),
916            sub,
917            None,
918        );
919    }
920
921    /// Convenience wrapper around [`submit_cull`](Self::submit_cull) for the
922    /// common case of one mesh with N instances.
923    ///
924    /// The renderer fills its scratch [`BatchMeta`] slot from `draw`, zeroes
925    /// its scratch counter, seeds the indirect entry, and runs a one-batch
926    /// cull. Plugins that only have a single mesh per submission don't have
927    /// to allocate either buffer themselves.
928    ///
929    /// `indirect_out` must hold one `DrawIndexedIndirect` entry (20 bytes).
930    pub fn submit_cull_single_mesh(
931        &mut self,
932        device: &wgpu::Device,
933        queue: &wgpu::Queue,
934        encoder: &mut wgpu::CommandEncoder,
935        frustum: &crate::camera::frustum::Frustum,
936        instance_aabbs: &wgpu::Buffer,
937        instance_count: u32,
938        visible_out: &wgpu::Buffer,
939        indirect_out: &wgpu::Buffer,
940        draw: crate::plugin_api::SingleMeshDraw,
941        shadow_pass: bool,
942    ) {
943        self.dispatch_cull_single_mesh(
944            device,
945            queue,
946            encoder,
947            frustum,
948            None,
949            instance_aabbs,
950            instance_count,
951            visible_out,
952            indirect_out,
953            draw,
954            shadow_pass,
955        );
956    }
957
958    /// Single-mesh shadow variant of
959    /// [`submit_cull_single_mesh`](Self::submit_cull_single_mesh).
960    pub fn submit_cull_shadow_single_mesh(
961        &mut self,
962        device: &wgpu::Device,
963        queue: &wgpu::Queue,
964        encoder: &mut wgpu::CommandEncoder,
965        cascade_idx: usize,
966        cascade_frustum: &crate::camera::frustum::Frustum,
967        instance_aabbs: &wgpu::Buffer,
968        instance_count: u32,
969        visible_out: &wgpu::Buffer,
970        indirect_out: &wgpu::Buffer,
971        draw: crate::plugin_api::SingleMeshDraw,
972    ) {
973        debug_assert!(cascade_idx < 4, "cascade_idx must be in 0..4");
974        let cascade_idx = cascade_idx.min(3);
975        self.dispatch_cull_single_mesh(
976            device,
977            queue,
978            encoder,
979            cascade_frustum,
980            Some(cascade_idx),
981            instance_aabbs,
982            instance_count,
983            visible_out,
984            indirect_out,
985            draw,
986            true,
987        );
988    }
989
990    #[allow(clippy::too_many_arguments)]
991    fn dispatch_cull_single_mesh(
992        &mut self,
993        device: &wgpu::Device,
994        queue: &wgpu::Queue,
995        encoder: &mut wgpu::CommandEncoder,
996        frustum: &crate::camera::frustum::Frustum,
997        cascade: Option<usize>,
998        instance_aabbs: &wgpu::Buffer,
999        instance_count: u32,
1000        visible_out: &wgpu::Buffer,
1001        indirect_out: &wgpu::Buffer,
1002        draw: crate::plugin_api::SingleMeshDraw,
1003        shadow_pass: bool,
1004    ) {
1005        if !self.instancing.gpu_culling_supported {
1006            return;
1007        }
1008        if self.instancing.cull_resources.is_none() {
1009            self.instancing.cull_resources =
1010                Some(crate::renderer::indirect::CullResources::new(device));
1011        }
1012        let cull = self.instancing.cull_resources.as_ref().unwrap();
1013        let (meta_buf, counter_buf) = cull.scratch_single_mesh_buffers();
1014        let meta = crate::plugin_api::BatchMeta {
1015            index_count: draw.index_count,
1016            first_index: draw.first_index,
1017            instance_offset: 0,
1018            instance_count,
1019            vis_offset: 0,
1020            is_transparent: 0,
1021            _pad: [0, 0],
1022        };
1023        queue.write_buffer(meta_buf, 0, bytemuck::bytes_of(&meta));
1024        queue.write_buffer(counter_buf, 0, &[0u8; 4]);
1025        // Seed the static fields of the indirect entry; the compute pass
1026        // overwrites `instance_count` with the final visible count.
1027        let seed: [u32; 5] = [
1028            draw.index_count,
1029            0,
1030            draw.first_index,
1031            draw.base_vertex as u32,
1032            draw.first_instance,
1033        ];
1034        queue.write_buffer(indirect_out, 0, bytemuck::cast_slice(&seed));
1035
1036        let sub = crate::plugin_api::CullSubmission {
1037            instance_aabbs,
1038            instance_count,
1039            batch_meta: meta_buf,
1040            batch_count: 1,
1041            counter: counter_buf,
1042            visible_out,
1043            indirect_out,
1044            shadow_pass,
1045        };
1046        cull.dispatch(encoder, device, queue, frustum, cascade, &sub, None);
1047    }
1048
1049    /// Register an [`ItemTypePlugin`](crate::plugin_api::ItemTypePlugin).
1050    ///
1051    /// Invokes the plugin's `init_gpu` against the current device and
1052    /// shared bind layout, then stores it keyed by `type_name()` for the
1053    /// remainder of the renderer's lifetime. Registering a second plugin
1054    /// with the same `type_name` replaces the first.
1055    ///
1056    /// The renderer will dispatch `prepare` and `paint` to the plugin on
1057    /// every frame where
1058    /// [`SceneFrame::submit_plugin_items`](crate::renderer::SceneFrame::submit_plugin_items)
1059    /// has populated a collection under the same name.
1060    pub fn with_item_type_plugin(
1061        &mut self,
1062        device: &wgpu::Device,
1063        mut plugin: Box<dyn crate::plugin_api::ItemTypePlugin>,
1064    ) {
1065        let shared = self.resources.shared_bindings();
1066        plugin.init_gpu(device, &shared);
1067        let name = plugin.type_name();
1068        self.item_type_plugins.insert(name, plugin);
1069    }
1070
1071    /// Returns true when an item-type plugin with `type_name` is
1072    /// registered.
1073    pub fn has_item_type_plugin(&self, type_name: &str) -> bool {
1074        self.item_type_plugins.contains_key(type_name)
1075    }
1076
1077    /// Walk registered item-type plugins, invoke `prepare` for each one
1078    /// that has a matching collection submitted on `frame.scene`, and
1079    /// return the concatenated command buffers.
1080    ///
1081    /// Called internally from the lib's prepare paths; not part of the
1082    /// consumer-facing API.
1083    pub(crate) fn dispatch_plugin_prepare(
1084        &mut self,
1085        device: &wgpu::Device,
1086        queue: &wgpu::Queue,
1087        frame: &FrameData,
1088    ) -> Vec<wgpu::CommandBuffer> {
1089        if self.item_type_plugins.is_empty() || frame.scene.plugin_items.is_empty() {
1090            return Vec::new();
1091        }
1092        self.plugin_frame_index = self.plugin_frame_index.wrapping_add(1);
1093        let mut bufs: Vec<wgpu::CommandBuffer> = Vec::new();
1094        for (name, plugin) in self.item_type_plugins.iter_mut() {
1095            if let Some(items) = frame.scene.plugin_items.get(*name) {
1096                // Constructed per plugin because `Jobs` borrows `&resources`
1097                // and the borrow only needs to live for this iteration.
1098                let ctx = crate::plugin_api::ItemFrameContext {
1099                    camera: &frame.camera.render_camera,
1100                    viewport_size: glam::Vec2::from(frame.camera.viewport_size),
1101                    viewport_index: frame.camera.viewport_index,
1102                    frame_index: self.plugin_frame_index,
1103                    jobs: crate::resources::Jobs::new(&self.resources),
1104                };
1105                bufs.extend(plugin.prepare(device, queue, &ctx, items.as_ref()));
1106            }
1107        }
1108        bufs
1109    }
1110
1111    /// Walk registered item-type plugins and invoke `paint` for each one
1112    /// that has a matching collection submitted on `frame.scene`.
1113    ///
1114    /// Called from inside the lib's HDR scene pass between built-in
1115    /// opaques and the skybox.
1116    pub(crate) fn dispatch_plugin_paint<'rp>(
1117        &'rp self,
1118        pass: &mut wgpu::RenderPass<'rp>,
1119        frame: &'rp FrameData,
1120    ) {
1121        if self.item_type_plugins.is_empty() || frame.scene.plugin_items.is_empty() {
1122            return;
1123        }
1124        let ctx = crate::plugin_api::PaintContext {
1125            camera: &frame.camera.render_camera,
1126            viewport_size: glam::Vec2::from(frame.camera.viewport_size),
1127            viewport_index: frame.camera.viewport_index,
1128            frame_index: self.plugin_frame_index,
1129        };
1130        for (name, plugin) in self.item_type_plugins.iter() {
1131            if let Some(items) = frame.scene.plugin_items.get(*name) {
1132                plugin.paint(pass, &ctx, items.as_ref());
1133            }
1134        }
1135    }
1136
1137    /// Walk registered plugins and invoke `paint_transparent` for each
1138    /// one whose collection is on `frame.scene`.
1139    ///
1140    /// Called from inside the lib's OIT render pass, after built-in
1141    /// transparent draws.
1142    pub(crate) fn dispatch_plugin_paint_transparent<'rp>(
1143        &'rp self,
1144        pass: &mut wgpu::RenderPass<'rp>,
1145        frame: &'rp FrameData,
1146    ) {
1147        if self.item_type_plugins.is_empty() || frame.scene.plugin_items.is_empty() {
1148            return;
1149        }
1150        let ctx = crate::plugin_api::PaintContext {
1151            camera: &frame.camera.render_camera,
1152            viewport_size: glam::Vec2::from(frame.camera.viewport_size),
1153            viewport_index: frame.camera.viewport_index,
1154            frame_index: self.plugin_frame_index,
1155        };
1156        for (name, plugin) in self.item_type_plugins.iter() {
1157            if let Some(items) = frame.scene.plugin_items.get(*name) {
1158                plugin.paint_transparent(pass, &ctx, items.as_ref());
1159            }
1160        }
1161    }
1162
1163    /// Walk registered plugins and invoke `cast_shadow_pass` for the
1164    /// given cascade.
1165    ///
1166    /// Currently unused: the shadow-pass call site inlines the plugin
1167    /// dispatch because the surrounding scope holds a mutable borrow of
1168    /// `self.resources` that blocks a normal `&self` method call. Kept
1169    /// alongside the other dispatchers as the natural shape; a future
1170    /// refactor that splits the resources borrow can switch back.
1171    #[allow(dead_code)]
1172    pub(crate) fn dispatch_plugin_shadow<'rp>(
1173        &'rp self,
1174        pass: &mut wgpu::RenderPass<'rp>,
1175        frame: &'rp FrameData,
1176        cascade_idx: u32,
1177        light_view_proj: glam::Mat4,
1178    ) {
1179        if self.item_type_plugins.is_empty() || frame.scene.plugin_items.is_empty() {
1180            return;
1181        }
1182        let ctx = crate::plugin_api::ShadowCastContext {
1183            cascade_idx,
1184            light_view_proj,
1185            camera: &frame.camera.render_camera,
1186            viewport_index: frame.camera.viewport_index,
1187            frame_index: self.plugin_frame_index,
1188        };
1189        for (name, plugin) in self.item_type_plugins.iter() {
1190            if let Some(items) = frame.scene.plugin_items.get(*name) {
1191                plugin.cast_shadow_pass(pass, &ctx, items.as_ref());
1192            }
1193        }
1194    }
1195
1196    /// Walk registered plugins and invoke `cull` for each one whose
1197    /// collection is on `frame.scene`.
1198    ///
1199    /// Called from the lib's prepare path once the camera frustum for
1200    /// the frame is known.
1201    pub(crate) fn dispatch_plugin_cull(
1202        &mut self,
1203        frustum: &crate::camera::frustum::Frustum,
1204        frame: &FrameData,
1205    ) {
1206        if self.item_type_plugins.is_empty() || frame.scene.plugin_items.is_empty() {
1207            return;
1208        }
1209        for (name, plugin) in self.item_type_plugins.iter_mut() {
1210            if let Some(items) = frame.scene.plugin_items.get(*name) {
1211                let ctx = crate::plugin_api::ItemFrameContext {
1212                    camera: &frame.camera.render_camera,
1213                    viewport_size: glam::Vec2::from(frame.camera.viewport_size),
1214                    viewport_index: frame.camera.viewport_index,
1215                    frame_index: self.plugin_frame_index,
1216                    jobs: crate::resources::Jobs::new(&self.resources),
1217                };
1218                plugin.cull(frustum, &ctx, items.as_ref());
1219            }
1220        }
1221    }
1222
1223    /// Walk registered item-type plugins and invoke `outline_mask` for
1224    /// each one whose collection is on `frame.scene`.
1225    ///
1226    /// Called from inside the lib's outline-mask render pass.
1227    pub(crate) fn dispatch_plugin_outline_mask<'rp>(
1228        &'rp self,
1229        pass: &mut wgpu::RenderPass<'rp>,
1230        frame: &'rp FrameData,
1231    ) {
1232        if self.item_type_plugins.is_empty() || frame.scene.plugin_items.is_empty() {
1233            return;
1234        }
1235        let ctx = crate::plugin_api::OutlineMaskContext {
1236            camera: &frame.camera.render_camera,
1237            viewport_size: glam::Vec2::from(frame.camera.viewport_size),
1238            viewport_index: frame.camera.viewport_index,
1239            frame_index: self.plugin_frame_index,
1240        };
1241        for (name, plugin) in self.item_type_plugins.iter() {
1242            if let Some(items) = frame.scene.plugin_items.get(*name) {
1243                plugin.outline_mask(pass, &ctx, items.as_ref());
1244            }
1245        }
1246    }
1247
1248    /// True when the device supports the features GPU-driven culling needs.
1249    ///
1250    /// Plugins should gate `submit_cull` calls on this. If false, the lib
1251    /// silently no-ops the submission and the plugin must fall back to
1252    /// direct draws.
1253    pub fn is_gpu_culling_supported(&self) -> bool {
1254        self.instancing.gpu_culling_supported
1255    }
1256
1257    /// Returns per-frame shadow and lighting pipeline statistics for debug inspection.
1258    ///
1259    /// All fields reflect the most recently completed `prepare` call (one frame
1260    /// behind the display). Returns default values before the first `prepare` call.
1261    pub fn shadow_debug_stats(&self) -> ShadowDebugStats {
1262        ShadowDebugStats {
1263            using_instanced_path: self.instancing.use_instancing,
1264            instanced_batch_count: self.instancing.batches.len(),
1265            cascade_count: self.shadow.last_cascade_count,
1266            cascade_splits: self.shadow.last_cascade_splits,
1267            shadow_atlas_resolution: self.shadow.last_shadow_atlas_resolution,
1268            shadow_extent_world: self.shadow.last_shadow_extent,
1269            contact_shadow_active: self.shadow.last_contact_shadow_active,
1270        }
1271    }
1272
1273    /// Read the debug values at a specific pixel from the per-fragment storage buffer.
1274    ///
1275    /// Returns `None` when debug_vis is inactive (no buffer allocated) or when `(x, y)`
1276    /// is outside the viewport. The four channels correspond to the current R/G/B channel
1277    /// selectors plus 1.0 for alpha.
1278    ///
1279    /// This submits a GPU-to-CPU copy and waits synchronously. Only call from outside
1280    /// a render pass (e.g., in the next frame's prepare step), not inside paint callbacks.
1281    ///
1282    /// The returned values are from the previous rendered frame.
1283    pub fn read_debug_pixel(
1284        &self,
1285        device: &wgpu::Device,
1286        queue: &wgpu::Queue,
1287        x: u32,
1288        y: u32,
1289    ) -> Option<[f32; 4]> {
1290        // Use the primary viewport slot (index 0).
1291        let slot = self.viewport_slots.first()?;
1292        let buf = slot.debug_frag_buf.as_ref()?;
1293        let (vw, vh) = slot.debug_frag_dims;
1294        if x >= vw || y >= vh {
1295            return None;
1296        }
1297        let byte_offset = ((y as u64) * (vw as u64) + (x as u64)) * 16;
1298        let staging = device.create_buffer(&wgpu::BufferDescriptor {
1299            label: None,
1300            size: 16,
1301            usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST,
1302            mapped_at_creation: false,
1303        });
1304        let mut encoder =
1305            device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
1306        encoder.copy_buffer_to_buffer(buf, byte_offset, &staging, 0, 16);
1307        queue.submit(Some(encoder.finish()));
1308        let slice = staging.slice(..);
1309        let (tx, rx) = std::sync::mpsc::channel::<Result<(), wgpu::BufferAsyncError>>();
1310        slice.map_async(wgpu::MapMode::Read, move |r| {
1311            let _ = tx.send(r);
1312        });
1313        let _ = device.poll(wgpu::PollType::Wait {
1314            submission_index: None,
1315            timeout: Some(std::time::Duration::from_secs(5)),
1316        });
1317        rx.recv().ok()?.ok()?;
1318        let data = slice.get_mapped_range();
1319        Some(bytemuck::pod_read_unaligned::<[f32; 4]>(&data))
1320    }
1321
1322    /// Upload a Gaussian splat set to the GPU.
1323    ///
1324    /// Call once per splat set at startup or when it changes. The returned
1325    /// [`GaussianSplatId`] is valid until [`remove_gaussian_splats`](Self::remove_gaussian_splats) is called.
1326    ///
1327    /// # Errors
1328    ///
1329    /// Returns [`ViewportError::InvalidGaussianSplatData`](crate::error::ViewportError::InvalidGaussianSplatData)
1330    /// if `data.positions` is empty or if `positions`, `scales`, `rotations`, and `opacities`
1331    /// differ in length.
1332    ///
1333    /// # Examples
1334    ///
1335    /// ```no_run
1336    /// # use viewport_lib::error::ViewportError;
1337    /// # use viewport_lib::renderer::{GaussianSplatData, ViewportRenderer};
1338    /// # fn demo(renderer: &mut ViewportRenderer, device: &wgpu::Device, queue: &wgpu::Queue) {
1339    /// let result = renderer.upload_gaussian_splats(device, queue, &GaussianSplatData::default());
1340    /// assert!(matches!(result, Err(ViewportError::InvalidGaussianSplatData { .. })));
1341    /// # }
1342    /// ```
1343    pub fn upload_gaussian_splats(
1344        &mut self,
1345        device: &wgpu::Device,
1346        queue: &wgpu::Queue,
1347        data: &GaussianSplatData,
1348    ) -> crate::error::ViewportResult<GaussianSplatId> {
1349        self.resources.upload_gaussian_splats(device, queue, data)
1350    }
1351
1352    /// Remove an uploaded Gaussian splat set by handle.
1353    ///
1354    /// After this call the `id` is invalid and must not be submitted in `SceneFrame`.
1355    pub fn remove_gaussian_splats(&mut self, id: GaussianSplatId) {
1356        self.resources.remove_gaussian_splats(id);
1357    }
1358
1359    /// Upload an equirectangular HDR environment map and precompute IBL textures.
1360    ///
1361    /// `pixels` is row-major RGBA f32 data (4 floats per texel), `width`x`height`.
1362    /// This rebuilds camera bind groups so shaders immediately see the new textures.
1363    ///
1364    /// # Errors
1365    ///
1366    /// Returns [`ViewportError::InvalidTextureData`](crate::error::ViewportError::InvalidTextureData)
1367    /// if `pixels.len()` does not equal `width * height * 4`.
1368    ///
1369    /// # Examples
1370    ///
1371    /// ```no_run
1372    /// # use viewport_lib::error::ViewportError;
1373    /// # use viewport_lib::renderer::ViewportRenderer;
1374    /// # fn demo(renderer: &mut ViewportRenderer, device: &wgpu::Device, queue: &wgpu::Queue) {
1375    /// // 2x2 RGBA image requires exactly 16 floats.
1376    /// let result = renderer.upload_environment_map(device, queue, &[0.0f32; 12], 2, 2);
1377    /// assert!(matches!(result, Err(ViewportError::InvalidTextureData { expected: 16, actual: 12 })));
1378    /// # }
1379    /// ```
1380    pub fn upload_environment_map(
1381        &mut self,
1382        device: &wgpu::Device,
1383        queue: &wgpu::Queue,
1384        pixels: &[f32],
1385        width: u32,
1386        height: u32,
1387    ) -> crate::error::ViewportResult<()> {
1388        crate::resources::environment::upload_environment_map(
1389            &mut self.resources,
1390            device,
1391            queue,
1392            pixels,
1393            width,
1394            height,
1395        )?;
1396        self.rebuild_camera_bind_groups(device);
1397        Ok(())
1398    }
1399
1400    /// Current state of an in-flight upload job.
1401    pub fn upload_status(&self, id: crate::resources::JobId) -> crate::resources::UploadStatus {
1402        self.resources.upload_status(id)
1403    }
1404
1405    /// Count of upload jobs still in flight.
1406    pub fn uploads_pending(&self) -> usize {
1407        self.resources.uploads_pending()
1408    }
1409
1410    /// Wall-clock work duration recorded for an async upload job. See
1411    /// [`ViewportGpuResources::job_duration`].
1412    pub fn job_duration(&self, id: crate::resources::JobId) -> Option<std::time::Duration> {
1413        self.resources.job_duration(id)
1414    }
1415
1416    /// Drop the recorded duration for `id` after reading it. See
1417    /// [`ViewportGpuResources::drop_job_duration`].
1418    pub fn drop_job_duration(&mut self, id: crate::resources::JobId) {
1419        self.resources.drop_job_duration(id);
1420    }
1421
1422    /// Start an asynchronous 3D volume texture upload. See
1423    /// [`ViewportGpuResources::begin_upload_volume`].
1424    pub fn begin_upload_volume(
1425        &mut self,
1426        device: &wgpu::Device,
1427        queue: &wgpu::Queue,
1428        data: Vec<f32>,
1429        dims: [u32; 3],
1430    ) -> crate::error::ViewportResult<crate::resources::JobId> {
1431        self.resources
1432            .begin_upload_volume(device, queue, data, dims)
1433    }
1434
1435    /// Take the volume id produced by a completed
1436    /// [`begin_upload_volume`](Self::begin_upload_volume) job.
1437    pub fn upload_result_volume(
1438        &mut self,
1439        id: crate::resources::JobId,
1440    ) -> crate::error::ViewportResult<crate::resources::VolumeId> {
1441        self.resources.upload_result_volume(id)
1442    }
1443
1444    /// Start an asynchronous marching-cubes-ready volume upload. See
1445    /// [`ViewportGpuResources::begin_upload_volume_for_mc`].
1446    pub fn begin_upload_volume_for_mc(
1447        &mut self,
1448        device: &wgpu::Device,
1449        queue: &wgpu::Queue,
1450        vol: crate::geometry::marching_cubes::VolumeData,
1451    ) -> crate::resources::JobId {
1452        self.resources
1453            .begin_upload_volume_for_mc(device, queue, vol)
1454    }
1455
1456    /// Take the [`VolumeGpuId`](crate::resources::VolumeGpuId) produced by a
1457    /// completed [`begin_upload_volume_for_mc`](Self::begin_upload_volume_for_mc) job.
1458    pub fn upload_result_volume_mc(
1459        &mut self,
1460        id: crate::resources::JobId,
1461    ) -> crate::error::ViewportResult<crate::resources::VolumeGpuId> {
1462        self.resources.upload_result_volume_mc(id)
1463    }
1464
1465    /// Start an asynchronous boundary-only volume mesh upload. See
1466    /// [`ViewportGpuResources::begin_upload_volume_mesh`].
1467    pub fn begin_upload_volume_mesh(
1468        &mut self,
1469        device: &wgpu::Device,
1470        data: crate::resources::volume_mesh::VolumeMeshData,
1471    ) -> crate::resources::JobId {
1472        self.resources.begin_upload_volume_mesh(device, data)
1473    }
1474
1475    /// Take the [`VolumeMeshItem`](crate::VolumeMeshItem)
1476    /// produced by a completed
1477    /// [`begin_upload_volume_mesh`](Self::begin_upload_volume_mesh) job.
1478    pub fn upload_result_volume_mesh(
1479        &mut self,
1480        id: crate::resources::JobId,
1481    ) -> crate::error::ViewportResult<crate::VolumeMeshItem> {
1482        self.resources.upload_result_volume_mesh(id)
1483    }
1484
1485    /// Start an asynchronous clipped volume mesh upload. See
1486    /// [`ViewportGpuResources::begin_upload_clipped_volume_mesh`].
1487    pub fn begin_upload_clipped_volume_mesh(
1488        &mut self,
1489        device: &wgpu::Device,
1490        data: crate::resources::volume_mesh::VolumeMeshData,
1491        clip_planes: Vec<[f32; 4]>,
1492    ) -> crate::resources::JobId {
1493        self.resources
1494            .begin_upload_clipped_volume_mesh(device, data, clip_planes)
1495    }
1496
1497    /// Take the [`VolumeMeshItem`](crate::VolumeMeshItem)
1498    /// produced by a completed
1499    /// [`begin_upload_clipped_volume_mesh`](Self::begin_upload_clipped_volume_mesh) job.
1500    pub fn upload_result_clipped_volume_mesh(
1501        &mut self,
1502        id: crate::resources::JobId,
1503    ) -> crate::error::ViewportResult<crate::VolumeMeshItem> {
1504        self.resources.upload_result_clipped_volume_mesh(id)
1505    }
1506
1507    /// Start an asynchronous sparse voxel grid upload. See
1508    /// [`ViewportGpuResources::begin_upload_sparse_volume_grid_data`].
1509    pub fn begin_upload_sparse_volume_grid_data(
1510        &mut self,
1511        device: &wgpu::Device,
1512        data: crate::resources::SparseVolumeGridData,
1513    ) -> crate::resources::JobId {
1514        self.resources
1515            .begin_upload_sparse_volume_grid_data(device, data)
1516    }
1517
1518    /// Take the [`MeshId`](crate::resources::mesh_store::MeshId) produced by a completed
1519    /// [`begin_upload_sparse_volume_grid_data`](Self::begin_upload_sparse_volume_grid_data)
1520    /// job.
1521    pub fn upload_result_sparse_volume_grid(
1522        &mut self,
1523        id: crate::resources::JobId,
1524    ) -> crate::error::ViewportResult<crate::resources::mesh_store::MeshId> {
1525        self.resources.upload_result_sparse_volume_grid(id)
1526    }
1527
1528    /// Start an asynchronous Gaussian splat upload. See
1529    /// [`ViewportGpuResources::begin_upload_gaussian_splats`].
1530    pub fn begin_upload_gaussian_splats(
1531        &mut self,
1532        device: &wgpu::Device,
1533        queue: &wgpu::Queue,
1534        data: crate::renderer::GaussianSplatData,
1535    ) -> crate::error::ViewportResult<crate::resources::JobId> {
1536        self.resources
1537            .begin_upload_gaussian_splats(device, queue, data)
1538    }
1539
1540    /// Take the [`GaussianSplatId`](crate::renderer::GaussianSplatId) produced by a
1541    /// completed [`begin_upload_gaussian_splats`](Self::begin_upload_gaussian_splats) job.
1542    pub fn upload_result_gaussian_splats(
1543        &mut self,
1544        id: crate::resources::JobId,
1545    ) -> crate::error::ViewportResult<crate::renderer::GaussianSplatId> {
1546        self.resources.upload_result_gaussian_splats(id)
1547    }
1548
1549    /// Start an asynchronous overlay texture upload. See
1550    /// [`ViewportGpuResources::begin_upload_overlay_texture`].
1551    pub fn begin_upload_overlay_texture(
1552        &mut self,
1553        device: &wgpu::Device,
1554        queue: &wgpu::Queue,
1555        width: u32,
1556        height: u32,
1557        rgba_data: Vec<u8>,
1558    ) -> crate::error::ViewportResult<crate::resources::JobId> {
1559        self.resources
1560            .begin_upload_overlay_texture(device, queue, width, height, rgba_data)
1561    }
1562
1563    /// Take the [`OverlayTextureId`](crate::renderer::OverlayTextureId) produced by a
1564    /// completed [`begin_upload_overlay_texture`](Self::begin_upload_overlay_texture) job.
1565    pub fn upload_result_overlay_texture(
1566        &mut self,
1567        id: crate::resources::JobId,
1568    ) -> crate::error::ViewportResult<crate::renderer::OverlayTextureId> {
1569        self.resources.upload_result_overlay_texture(id)
1570    }
1571
1572    /// True when no upload jobs are in flight.
1573    pub fn all_uploads_complete(&self) -> bool {
1574        self.resources.all_uploads_complete()
1575    }
1576
1577    /// Register a callback to fire when an upload job finishes. See
1578    /// [`ViewportGpuResources::on_upload_complete`] for the semantics.
1579    pub fn on_upload_complete<F>(&mut self, id: crate::resources::JobId, cb: F)
1580    where
1581        F: FnOnce(&crate::resources::UploadStatus) + Send + 'static,
1582    {
1583        self.resources.on_upload_complete(id, cb);
1584    }
1585
1586    /// Start an asynchronous albedo texture upload. See
1587    /// [`ViewportGpuResources::begin_upload_texture`] for the semantics.
1588    pub fn begin_upload_texture(
1589        &mut self,
1590        device: &wgpu::Device,
1591        queue: &wgpu::Queue,
1592        width: u32,
1593        height: u32,
1594        rgba: Vec<u8>,
1595    ) -> crate::error::ViewportResult<crate::resources::JobId> {
1596        self.resources
1597            .begin_upload_texture(device, queue, width, height, rgba)
1598    }
1599
1600    /// Start an asynchronous normal-map upload. See
1601    /// [`ViewportGpuResources::begin_upload_normal_map`] for the semantics.
1602    pub fn begin_upload_normal_map(
1603        &mut self,
1604        device: &wgpu::Device,
1605        queue: &wgpu::Queue,
1606        width: u32,
1607        height: u32,
1608        rgba: Vec<u8>,
1609    ) -> crate::error::ViewportResult<crate::resources::JobId> {
1610        self.resources
1611            .begin_upload_normal_map(device, queue, width, height, rgba)
1612    }
1613
1614    /// Take the texture id from a completed async texture upload. See
1615    /// [`ViewportGpuResources::upload_result_texture`] for the error
1616    /// semantics.
1617    pub fn upload_result_texture(
1618        &mut self,
1619        id: crate::resources::JobId,
1620    ) -> crate::error::ViewportResult<u64> {
1621        self.resources.upload_result_texture(id)
1622    }
1623
1624    /// Start an asynchronous mesh upload.
1625    ///
1626    /// Returns a `JobId` immediately. The CPU prep (tangent computation,
1627    /// vertex repack, normal-line build) runs on a worker thread; GPU
1628    /// buffer creation and store insertion run on the main thread during
1629    /// the next `process_uploads` call after the worker finishes. Once the
1630    /// status is `Ready`, take the produced `MeshId` with
1631    /// `upload_result_mesh`.
1632    ///
1633    /// Ownership of `data` transfers into the worker; clone at the call
1634    /// site if you need to retain it.
1635    ///
1636    /// # Errors
1637    ///
1638    /// Same validation errors as `upload_mesh_data` (empty mesh, length
1639    /// mismatch, invalid vertex index), all reported before the job is
1640    /// submitted.
1641    pub fn begin_upload_mesh_data(
1642        &mut self,
1643        device: &wgpu::Device,
1644        data: crate::resources::MeshData,
1645    ) -> crate::error::ViewportResult<crate::resources::JobId> {
1646        self.resources.begin_upload_mesh_data(device, data)
1647    }
1648
1649    /// Take the `MeshId` produced by a completed `begin_upload_mesh_data`
1650    /// job. See [`ViewportGpuResources::upload_result_mesh`] for the error
1651    /// semantics.
1652    pub fn upload_result_mesh(
1653        &mut self,
1654        id: crate::resources::JobId,
1655    ) -> crate::error::ViewportResult<crate::resources::mesh_store::MeshId> {
1656        self.resources.upload_result_mesh(id)
1657    }
1658
1659    /// Start an asynchronous environment-map upload.
1660    ///
1661    /// Returns immediately with a `JobId`. The caller drives the upload-job
1662    /// runner from the renderer's prepare path each frame; once the job
1663    /// reports `Ready`, the IBL textures are live on the renderer and a
1664    /// subsequent call to `rebuild_camera_bind_groups` makes them visible
1665    /// to shaders.
1666    ///
1667    /// Ownership of `pixels` transfers into the background worker.
1668    ///
1669    /// # Errors
1670    ///
1671    /// Returns [`ViewportError::InvalidTextureData`](crate::error::ViewportError::InvalidTextureData)
1672    /// if `pixels.len() != width * height * 4`.
1673    pub fn begin_upload_environment_map(
1674        &mut self,
1675        device: &wgpu::Device,
1676        queue: &wgpu::Queue,
1677        pixels: Vec<f32>,
1678        width: u32,
1679        height: u32,
1680    ) -> crate::error::ViewportResult<crate::resources::JobId> {
1681        crate::resources::environment::begin_upload_environment_map(
1682            &mut self.resources,
1683            device,
1684            queue,
1685            pixels,
1686            width,
1687            height,
1688        )
1689    }
1690
1691    /// Rebuild the primary and per-viewport camera bind groups.
1692    ///
1693    /// Call after IBL textures are uploaded so the shaders see the new
1694    /// environment. The synchronous `upload_environment_map` does this
1695    /// internally; consumers driving the async path through
1696    /// `begin_upload_environment_map` should call this themselves once the
1697    /// matching job reports `Ready`.
1698    pub fn rebuild_camera_bind_groups(&mut self, device: &wgpu::Device) {
1699        self.resources.camera_bind_group = self.resources.create_camera_bind_group(
1700            device,
1701            &self.resources.camera_uniform_buf,
1702            &self.resources.clip_planes_uniform_buf,
1703            &self.resources.shadow_info_buf,
1704            &self.resources.clip_volume_uniform_buf,
1705            &self.resources.debug_frag_sentinel_buf,
1706            "camera_bind_group",
1707        );
1708
1709        for slot in &mut self.viewport_slots {
1710            let dbg_buf = slot
1711                .debug_frag_buf
1712                .as_ref()
1713                .unwrap_or(&self.resources.debug_frag_sentinel_buf);
1714            slot.camera_bind_group = self.resources.create_camera_bind_group(
1715                device,
1716                &slot.camera_buf,
1717                &slot.clip_planes_buf,
1718                &slot.shadow_info_buf,
1719                &slot.clip_volume_buf,
1720                dbg_buf,
1721                "per_viewport_camera_bg",
1722            );
1723        }
1724    }
1725
1726    /// Ensure a per-viewport slot exists for `viewport_index`.
1727    ///
1728    /// Creates a full `ViewportSlot` with independent uniform buffers for camera,
1729    /// clip planes, clip volume, shadow info, and grid. The camera bind group
1730    /// references this slot's per-viewport buffers plus shared scene-global
1731    /// resources. Slots are created lazily and never destroyed.
1732    fn ensure_viewport_slot(&mut self, device: &wgpu::Device, viewport_index: usize) {
1733        while self.viewport_slots.len() <= viewport_index {
1734            let camera_buf = device.create_buffer(&wgpu::BufferDescriptor {
1735                label: Some("vp_camera_buf"),
1736                size: std::mem::size_of::<CameraUniform>() as u64,
1737                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1738                mapped_at_creation: false,
1739            });
1740            let clip_planes_buf = device.create_buffer(&wgpu::BufferDescriptor {
1741                label: Some("vp_clip_planes_buf"),
1742                size: std::mem::size_of::<ClipPlanesUniform>() as u64,
1743                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1744                mapped_at_creation: false,
1745            });
1746            let clip_volume_buf = device.create_buffer(&wgpu::BufferDescriptor {
1747                label: Some("vp_clip_volume_buf"),
1748                size: std::mem::size_of::<ClipVolumesUniform>() as u64,
1749                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1750                mapped_at_creation: false,
1751            });
1752            // Seeded with the latest shadow atlas uniform rather than zeros:
1753            // prepare_scene_internal writes shadow info only to slots that
1754            // exist at that point, so a slot created later in the same frame
1755            // would otherwise render its first frame with zeroed cascade
1756            // matrices (NaN shadow UVs, everything shadowed).
1757            let shadow_info_buf = device.create_buffer(&wgpu::BufferDescriptor {
1758                label: Some("vp_shadow_info_buf"),
1759                size: std::mem::size_of::<ShadowAtlasUniform>() as u64,
1760                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1761                mapped_at_creation: true,
1762            });
1763            shadow_info_buf
1764                .slice(..)
1765                .get_mapped_range_mut()
1766                .copy_from_slice(bytemuck::cast_slice(&[self
1767                    .shadow
1768                    .last_shadow_atlas_uniform]));
1769            shadow_info_buf.unmap();
1770            let grid_buf = device.create_buffer(&wgpu::BufferDescriptor {
1771                label: Some("vp_grid_buf"),
1772                size: std::mem::size_of::<GridUniform>() as u64,
1773                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1774                mapped_at_creation: false,
1775            });
1776
1777            let camera_bind_group = self.resources.create_camera_bind_group(
1778                device,
1779                &camera_buf,
1780                &clip_planes_buf,
1781                &shadow_info_buf,
1782                &clip_volume_buf,
1783                &self.resources.debug_frag_sentinel_buf,
1784                "per_viewport_camera_bg",
1785            );
1786
1787            let grid_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1788                label: Some("vp_grid_bind_group"),
1789                layout: &self.resources.grid_bind_group_layout,
1790                entries: &[wgpu::BindGroupEntry {
1791                    binding: 0,
1792                    resource: grid_buf.as_entire_binding(),
1793                }],
1794            });
1795
1796            // Per-viewport gizmo buffers (initial mesh: Translate, no hover, identity orientation).
1797            let (gizmo_verts, gizmo_indices) = crate::interaction::gizmo::build_gizmo_mesh(
1798                crate::interaction::gizmo::GizmoMode::Translate,
1799                crate::interaction::gizmo::GizmoAxis::None,
1800                glam::Quat::IDENTITY,
1801            );
1802            let gizmo_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1803                label: Some("vp_gizmo_vertex_buf"),
1804                size: (std::mem::size_of::<crate::resources::Vertex>() * gizmo_verts.len().max(1))
1805                    as u64,
1806                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1807                mapped_at_creation: true,
1808            });
1809            gizmo_vertex_buffer
1810                .slice(..)
1811                .get_mapped_range_mut()
1812                .copy_from_slice(bytemuck::cast_slice(&gizmo_verts));
1813            gizmo_vertex_buffer.unmap();
1814            let gizmo_index_count = gizmo_indices.len() as u32;
1815            let gizmo_index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1816                label: Some("vp_gizmo_index_buf"),
1817                size: (std::mem::size_of::<u32>() * gizmo_indices.len().max(1)) as u64,
1818                usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
1819                mapped_at_creation: true,
1820            });
1821            gizmo_index_buffer
1822                .slice(..)
1823                .get_mapped_range_mut()
1824                .copy_from_slice(bytemuck::cast_slice(&gizmo_indices));
1825            gizmo_index_buffer.unmap();
1826            let gizmo_uniform = crate::interaction::gizmo::GizmoUniform {
1827                model: glam::Mat4::IDENTITY.to_cols_array_2d(),
1828            };
1829            let gizmo_uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
1830                label: Some("vp_gizmo_uniform_buf"),
1831                size: std::mem::size_of::<crate::interaction::gizmo::GizmoUniform>() as u64,
1832                usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
1833                mapped_at_creation: true,
1834            });
1835            gizmo_uniform_buf
1836                .slice(..)
1837                .get_mapped_range_mut()
1838                .copy_from_slice(bytemuck::cast_slice(&[gizmo_uniform]));
1839            gizmo_uniform_buf.unmap();
1840            let gizmo_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1841                label: Some("vp_gizmo_bind_group"),
1842                layout: &self.resources.gizmo_bind_group_layout,
1843                entries: &[wgpu::BindGroupEntry {
1844                    binding: 0,
1845                    resource: gizmo_uniform_buf.as_entire_binding(),
1846                }],
1847            });
1848
1849            // Per-viewport axes vertex buffer (2048 vertices = enough for all axes geometry).
1850            let axes_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
1851                label: Some("vp_axes_vertex_buf"),
1852                size: (std::mem::size_of::<crate::widgets::axes_indicator::AxesVertex>() * 2048)
1853                    as u64,
1854                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
1855                mapped_at_creation: false,
1856            });
1857
1858            self.viewport_slots.push(ViewportSlot {
1859                camera_buf,
1860                clip_planes_buf,
1861                clip_volume_buf,
1862                shadow_info_buf,
1863                grid_buf,
1864                camera_bind_group,
1865                grid_bind_group,
1866                hdr: None,
1867                debug_frag_buf: None,
1868                debug_frag_dims: (0, 0),
1869                outline_object_buffers: Vec::new(),
1870                splat_outline_buffers: Vec::new(),
1871                volume_outline_indices: Vec::new(),
1872                glyph_outline_indices: Vec::new(),
1873                tensor_glyph_outline_indices: Vec::new(),
1874                sprite_outline_indices: Vec::new(),
1875                raw_geom_outline_buffers: Vec::new(),
1876                screen_rect_outline_buffers: Vec::new(),
1877                implicit_outline_indices: Vec::new(),
1878                mc_outline_data: Vec::new(),
1879                streamtube_outline_items: Vec::new(),
1880                tube_outline_items: Vec::new(),
1881                ribbon_outline_items: Vec::new(),
1882                polyline_outline_indices: Vec::new(),
1883                xray_object_buffers: Vec::new(),
1884                constraint_line_buffers: Vec::new(),
1885                cap_buffers: Vec::new(),
1886                clip_plane_fill_buffers: Vec::new(),
1887                clip_plane_line_buffers: Vec::new(),
1888                axes_vertex_buffer,
1889                axes_vertex_count: 0,
1890                gizmo_uniform_buf,
1891                gizmo_bind_group,
1892                gizmo_vertex_buffer,
1893                gizmo_index_buffer,
1894                gizmo_index_count,
1895                sub_highlight: None,
1896                sub_highlight_generation: u64::MAX,
1897                dyn_res: None,
1898                hdr_callback: None,
1899            });
1900        }
1901    }
1902
1903    // -----------------------------------------------------------------------
1904    // Multi-viewport public API
1905    // -----------------------------------------------------------------------
1906
1907    /// Create a new viewport slot and return its handle.
1908    ///
1909    /// The returned [`ViewportId`] is stable for the lifetime of the renderer.
1910    /// Pass it to [`prepare_viewport`](Self::prepare_viewport),
1911    /// [`paint_viewport`](Self::paint_viewport), and
1912    /// [`render_viewport`](Self::render_viewport) each frame.
1913    ///
1914    /// Also set the viewport slot on the camera frame when building the
1915    /// [`FrameData`] for this viewport:
1916    /// ```rust,ignore
1917    /// let id = renderer.create_viewport(&device);
1918    /// let frame = FrameData {
1919    ///     camera: CameraFrame::from_camera(&cam, size).with_viewport_id(id),
1920    ///     ..Default::default()
1921    /// };
1922    /// ```
1923    pub fn create_viewport(&mut self, device: &wgpu::Device) -> ViewportId {
1924        let idx = self.viewport_slots.len();
1925        self.ensure_viewport_slot(device, idx);
1926        ViewportId(idx)
1927    }
1928
1929    /// Release the heavy GPU texture memory (HDR targets, OIT, bloom, SSAO) held
1930    /// by `id`.
1931    ///
1932    /// The slot index is not reclaimed : future calls with this `ViewportId` will
1933    /// lazily recreate the texture resources as needed.  This is useful when a
1934    /// viewport is hidden or minimised and you want to reduce VRAM pressure without
1935    /// invalidating the handle.
1936    pub fn destroy_viewport(&mut self, id: ViewportId) {
1937        if let Some(slot) = self.viewport_slots.get_mut(id.0) {
1938            slot.hdr = None;
1939        }
1940    }
1941
1942    /// Returns the owned-encoder rendering path.
1943    ///
1944    /// Use when you own the window loop and wgpu encoder (winit, raw wgpu).
1945    /// See [`OwnedPath`] for available methods.
1946    pub fn owned(&mut self) -> OwnedPath<'_> {
1947        OwnedPath { renderer: self }
1948    }
1949
1950    /// Returns the pass-based rendering path.
1951    ///
1952    /// Use when a framework provides you with a render pass (eframe, iced).
1953    /// See [`PassPath`] for available methods.
1954    pub fn pass(&mut self) -> PassPath<'_> {
1955        PassPath { renderer: self }
1956    }
1957
1958    /// Returns a read-only paint view for framework paint callbacks.
1959    ///
1960    /// Use this in callbacks where only a shared reference to the renderer is
1961    /// available (e.g. eframe's `CallbackTrait::paint` where `callback_resources`
1962    /// is `&CallbackResources`). Exposes only the paint methods, not prepare.
1963    pub fn pass_view(&self) -> PassView<'_> {
1964        PassView { renderer: self }
1965    }
1966
1967    /// Prepare shared scene data.  Call **once per frame**, before any
1968    /// [`prepare_viewport`](Self::prepare_viewport) calls.
1969    ///
1970    /// `frame` provides the scene content (`frame.scene`) and the primary camera
1971    /// used for shadow cascade framing (`frame.camera`).  In a multi-viewport
1972    /// setup use any one viewport's `FrameData` here : typically the perspective
1973    /// view : as the shadow framing reference.
1974    ///
1975    /// `scene_effects` carries the scene-global effects: lighting, environment
1976    /// map, and compute filters.  Obtain it by constructing [`SceneEffects`]
1977    /// directly or via [`EffectsFrame::split`].
1978    pub(crate) fn prepare_scene(
1979        &mut self,
1980        device: &wgpu::Device,
1981        queue: &wgpu::Queue,
1982        frame: &FrameData,
1983        scene_effects: &SceneEffects<'_>,
1984    ) {
1985        self.prepare_scene_internal(device, queue, frame, scene_effects);
1986    }
1987
1988    /// Prepare per-viewport GPU state (camera, clip planes, overlays, axes).
1989    ///
1990    /// Call once per viewport per frame, **after** [`prepare_scene`](Self::prepare_scene).
1991    ///
1992    /// `id` must have been obtained from [`create_viewport`](Self::create_viewport).
1993    /// `frame.camera.viewport_index` must equal the slot for `id`; use
1994    /// [`CameraFrame::with_viewport_id`] when building the frame.
1995    pub(crate) fn prepare_viewport(
1996        &mut self,
1997        device: &wgpu::Device,
1998        queue: &wgpu::Queue,
1999        id: ViewportId,
2000        frame: &FrameData,
2001    ) {
2002        debug_assert_eq!(
2003            frame.camera.viewport_index, id.0,
2004            "frame.camera.viewport_index ({}) must equal the ViewportId ({}); \
2005             use CameraFrame::with_viewport_id(id)",
2006            frame.camera.viewport_index, id.0,
2007        );
2008        let (_, viewport_fx) = frame.effects.split();
2009        self.prepare_viewport_internal(device, queue, frame, &viewport_fx);
2010    }
2011
2012    /// Issue draw calls for `id` into a render pass with any lifetime.
2013    ///
2014    /// Identical to [`paint_viewport`](Self::paint_viewport) but accepts a render pass with a
2015    /// non-`'static` lifetime, making it usable from winit, iced, or raw wgpu where the encoder
2016    /// creates its own render pass.
2017    pub(crate) fn paint_viewport_to<'rp>(
2018        &self,
2019        render_pass: &mut wgpu::RenderPass<'rp>,
2020        id: ViewportId,
2021        frame: &FrameData,
2022    ) {
2023        let vp_idx = id.0;
2024        let camera_bg = self.viewport_camera_bind_group(vp_idx);
2025        let grid_bg = self.viewport_grid_bind_group(vp_idx);
2026        let vp_slot = self.viewport_slots.get(vp_idx);
2027        emit_draw_calls!(
2028            &self.resources,
2029            &mut *render_pass,
2030            frame,
2031            self.instancing.use_instancing,
2032            &self.instancing.batches,
2033            camera_bg,
2034            grid_bg,
2035            &self.compute_filter_results,
2036            vp_slot,
2037            &self.mesh_uniforms.wireframe_bind_groups,
2038            &self.mesh_uniforms.bind_groups
2039        );
2040        emit_scivis_draw_calls!(
2041            &self.resources,
2042            &mut *render_pass,
2043            &self.point_cloud_gpu_data,
2044            &self.glyph_gpu_data,
2045            &self.polyline_gpu_data,
2046            &self.volume_gpu_data,
2047            &self.streamtube_gpu_data,
2048            camera_bg,
2049            &self.tube_gpu_data,
2050            &self.image_slice_gpu_data,
2051            &self.tensor_glyph_gpu_data,
2052            &self.ribbon_gpu_data,
2053            &self.volume_surface_slice_gpu_data,
2054            &self.sprite_gpu_data,
2055            &self.mesh_instance_gpu_data,
2056            false
2057        );
2058        // Gaussian splats (alpha-blended, back-to-front sorted, no depth write).
2059        if !self.gaussian_splat_draw_data.is_empty() {
2060            if let Some(ref dual) = self.resources.gaussian_splat_pipeline {
2061                render_pass.set_pipeline(dual.for_format(false));
2062                render_pass.set_bind_group(0, camera_bg, &[]);
2063                for dd in &self.gaussian_splat_draw_data {
2064                    if dd.wireframe {
2065                        continue;
2066                    }
2067                    if let Some(set) = self.resources.gaussian_splat_store.get(dd.store_index) {
2068                        if let Some(Some(vp_sort)) = set.viewport_sort.get(dd.viewport_index) {
2069                            render_pass.set_bind_group(1, &vp_sort.render_bg, &[]);
2070                            render_pass.draw(0..6, 0..dd.count);
2071                        }
2072                    }
2073                }
2074            }
2075        }
2076        // TransparentVolumeMesh boundary wireframe overlay.
2077        if !self.mesh_uniforms.tvm_wireframe_draws.is_empty() {
2078            if let Some(ref tvm_bg) = self.mesh_uniforms.tvm_wireframe_bg {
2079                render_pass.set_bind_group(0, camera_bg, &[]);
2080                for mesh_id in &self.mesh_uniforms.tvm_wireframe_draws {
2081                    if let Some(mesh) = self.resources.mesh_store.get(*mesh_id) {
2082                        render_pass.set_pipeline(&self.resources.wireframe_pipeline);
2083                        render_pass.set_bind_group(2, &self.resources.deform.dummy_bind_group, &[]);
2084                        render_pass.set_bind_group(1, tvm_bg, &[]);
2085                        render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
2086                        render_pass.set_index_buffer(
2087                            mesh.edge_index_buffer.slice(..),
2088                            wgpu::IndexFormat::Uint32,
2089                        );
2090                        render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
2091                    }
2092                }
2093            }
2094        }
2095        // Shadow atlas viewer overlay.
2096        if frame.effects.show_shadow_atlas {
2097            render_pass.set_pipeline(&self.resources.shadow_atlas_viewer_pipeline);
2098            render_pass.set_bind_group(0, &self.resources.shadow_atlas_viewer_bg, &[]);
2099            render_pass.draw(0..6, 0..1);
2100        }
2101    }
2102
2103    /// Return a reference to the camera bind group for the given viewport slot.
2104    ///
2105    /// Falls back to `resources.camera_bind_group` if no per-viewport slot
2106    /// exists (e.g. in single-viewport mode before the first prepare call).
2107    fn viewport_camera_bind_group(&self, viewport_index: usize) -> &wgpu::BindGroup {
2108        self.viewport_slots
2109            .get(viewport_index)
2110            .map(|slot| &slot.camera_bind_group)
2111            .unwrap_or(&self.resources.camera_bind_group)
2112    }
2113
2114    /// Return a reference to the grid bind group for the given viewport slot.
2115    ///
2116    /// Falls back to `resources.grid_bind_group` if no per-viewport slot exists.
2117    fn viewport_grid_bind_group(&self, viewport_index: usize) -> &wgpu::BindGroup {
2118        self.viewport_slots
2119            .get(viewport_index)
2120            .map(|slot| &slot.grid_bind_group)
2121            .unwrap_or(&self.resources.grid_bind_group)
2122    }
2123
2124    /// Ensure the dyn-res intermediate render target exists for `vp_idx` at the
2125    /// given `scaled_size`, creating or recreating it when size changes.
2126    ///
2127    /// `surface_size` is the native output dimensions (used to size the upscale
2128    /// blit correctly). `ensure_dyn_res_pipeline` is called automatically.
2129    pub(crate) fn ensure_dyn_res_target(
2130        &mut self,
2131        device: &wgpu::Device,
2132        vp_idx: usize,
2133        scaled_size: [u32; 2],
2134        surface_size: [u32; 2],
2135    ) {
2136        self.resources.ensure_dyn_res_pipeline(device);
2137        let needs_create = match &self.viewport_slots[vp_idx].dyn_res {
2138            None => true,
2139            Some(dr) => dr.scaled_size != scaled_size || dr.surface_size != surface_size,
2140        };
2141        if needs_create {
2142            let target = self
2143                .resources
2144                .create_dyn_res_target(device, scaled_size, surface_size);
2145            self.viewport_slots[vp_idx].dyn_res = Some(target);
2146        }
2147    }
2148
2149    /// Ensure per-viewport HDR state exists for `viewport_index` at dimensions `w`x`h`.
2150    ///
2151    /// Calls `ensure_hdr_shared` once to initialise shared pipelines/BGLs/samplers, then
2152    /// lazily creates or resizes the `ViewportHdrState` inside the slot. Idempotent: if the
2153    /// slot already has HDR state at the correct size nothing is recreated.
2154    pub(crate) fn ensure_viewport_hdr(
2155        &mut self,
2156        device: &wgpu::Device,
2157        queue: &wgpu::Queue,
2158        viewport_index: usize,
2159        w: u32,
2160        h: u32,
2161        ssaa_factor: u32,
2162        render_scale: f32,
2163    ) {
2164        let format = self.resources.target_format;
2165        // Ensure shared infrastructure (pipelines, BGLs, samplers) exists.
2166        self.resources.ensure_hdr_shared(device, queue, format);
2167        // When render_scale < 1.0, the HDR upscale path needs the dyn_res
2168        // pipeline and sampler for the final upscale-blit to output resolution.
2169        if render_scale < 1.0 - 0.001 {
2170            self.resources.ensure_dyn_res_pipeline(device);
2171        }
2172        // Compute the scene-resolution render target size.
2173        let scale = render_scale.clamp(0.1, 1.0);
2174        let scene_w = ((w as f32) * scale).round() as u32;
2175        let scene_h = ((h as f32) * scale).round() as u32;
2176        // Ensure the slot exists.
2177        self.ensure_viewport_slot(device, viewport_index);
2178        let slot = &mut self.viewport_slots[viewport_index];
2179        // Create or resize the per-viewport HDR state.
2180        let needs_create = match &slot.hdr {
2181            None => true,
2182            Some(s) => {
2183                s.output_size != [w, h]
2184                    || s.scene_size != [scene_w.max(1), scene_h.max(1)]
2185                    || s.ssaa_factor != ssaa_factor
2186            }
2187        };
2188        if needs_create {
2189            slot.hdr = Some(self.resources.create_hdr_viewport_state(
2190                device,
2191                queue,
2192                format,
2193                w,
2194                h,
2195                scene_w.max(1),
2196                scene_h.max(1),
2197                ssaa_factor,
2198            ));
2199        }
2200    }
2201}