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