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