Skip to main content

viewport_lib/renderer/
mod.rs

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