Skip to main content

viewport_lib/renderer/
render.rs

1use super::*;
2use wgpu::util::DeviceExt;
3
4impl ViewportRenderer {
5    pub(crate) fn paint_to<'rp>(&self, render_pass: &mut wgpu::RenderPass<'rp>, frame: &FrameData) {
6        let vp_idx = frame.camera.viewport_index;
7        let camera_bg = self.viewport_camera_bind_group(vp_idx);
8        let grid_bg = self.viewport_grid_bind_group(vp_idx);
9        let vp_slot = self.viewport_slots.get(vp_idx);
10        emit_draw_calls!(
11            &self.resources,
12            &mut *render_pass,
13            frame,
14            self.use_instancing,
15            &self.instanced_batches,
16            camera_bg,
17            grid_bg,
18            &self.compute_filter_results,
19            vp_slot,
20            &self.wireframe_bind_groups,
21            &self.per_item_object_bind_groups
22        );
23        emit_scivis_draw_calls!(
24            &self.resources,
25            &mut *render_pass,
26            &self.point_cloud_gpu_data,
27            &self.glyph_gpu_data,
28            &self.polyline_gpu_data,
29            &self.volume_gpu_data,
30            &self.streamtube_gpu_data,
31            camera_bg,
32            &self.tube_gpu_data,
33            &self.image_slice_gpu_data,
34            &self.tensor_glyph_gpu_data,
35            &self.ribbon_gpu_data,
36            &self.volume_surface_slice_gpu_data,
37            &self.sprite_gpu_data,
38            &self.mesh_instance_gpu_data,
39            false
40        );
41        // Gaussian splats (alpha-blended, back-to-front sorted, no depth write).
42        if !self.gaussian_splat_draw_data.is_empty() {
43            if let Some(ref dual) = self.resources.gaussian_splat_pipeline {
44                render_pass.set_pipeline(dual.for_format(false));
45                render_pass.set_bind_group(0, camera_bg, &[]);
46                for dd in &self.gaussian_splat_draw_data {
47                    if dd.wireframe {
48                        continue;
49                    }
50                    if let Some(set) = self.resources.gaussian_splat_store.get(dd.store_index) {
51                        if let Some(Some(vp_sort)) = set.viewport_sort.get(dd.viewport_index) {
52                            render_pass.set_bind_group(1, &vp_sort.render_bg, &[]);
53                            render_pass.draw(0..6, 0..dd.count);
54                        }
55                    }
56                }
57            }
58        }
59        // TransparentVolumeMesh boundary wireframe overlay.
60        if !self.tvm_wireframe_draws.is_empty() {
61            if let Some(ref tvm_bg) = self.tvm_wireframe_bg {
62                render_pass.set_bind_group(0, camera_bg, &[]);
63                for mesh_id in &self.tvm_wireframe_draws {
64                    if let Some(mesh) = self.resources.mesh_store.get(*mesh_id) {
65                        render_pass.set_pipeline(&self.resources.wireframe_pipeline);
66                        render_pass.set_bind_group(2, &self.resources.deform.dummy_bind_group, &[]);
67                        render_pass.set_bind_group(1, tvm_bg, &[]);
68                        render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
69                        render_pass.set_index_buffer(
70                            mesh.edge_index_buffer.slice(..),
71                            wgpu::IndexFormat::Uint32,
72                        );
73                        render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
74                    }
75                }
76            }
77        }
78        // GPU implicit surface (depth-writes enabled, LessEqual compare).
79        if !self.implicit_gpu_data.is_empty() {
80            if let Some(ref dual) = self.resources.implicit_pipeline {
81                render_pass.set_pipeline(dual.for_format(false));
82                render_pass.set_bind_group(0, camera_bg, &[]);
83                for gpu in &self.implicit_gpu_data {
84                    render_pass.set_bind_group(1, &gpu.bind_group, &[]);
85                    render_pass.draw(0..6, 0..1);
86                }
87            }
88        }
89        // GPU marching cubes indirect draw.
90        if !self.mc_gpu_data.is_empty() {
91            render_pass.set_bind_group(0, camera_bg, &[]);
92            for mc in &self.mc_gpu_data {
93                let vol = &self.resources.mc_volumes[mc.volume_idx];
94                if mc.wireframe || frame.viewport.wireframe_mode {
95                    if let Some(ref dual) = self.resources.mc_wireframe_pipeline {
96                        render_pass.set_pipeline(dual.for_format(false));
97                        for (slab, wire_bg) in vol.slabs.iter().zip(mc.wire_slab_bgs.iter()) {
98                            render_pass.set_bind_group(1, wire_bg, &[]);
99                            render_pass.draw_indirect(&slab.wire_indirect_buf, 0);
100                        }
101                    }
102                } else if let Some(ref dual) = self.resources.mc_surface_pipeline {
103                    render_pass.set_pipeline(dual.for_format(false));
104                    render_pass.set_bind_group(1, &mc.render_bg, &[]);
105                    for slab in &vol.slabs {
106                        render_pass.set_vertex_buffer(0, slab.vertex_buf.slice(..));
107                        render_pass.draw_indirect(&slab.indirect_buf, 0);
108                    }
109                }
110            }
111        }
112        // Outline composite after all scene content so translucent layers don't overdraw.
113        emit_outline_composite!(&self.resources, &mut *render_pass, vp_slot);
114        // Sub-object highlight (LDR path) : face fill, edge lines, vertex/point sprites.
115        if let Some(sub_hl) = self
116            .viewport_slots
117            .get(vp_idx)
118            .and_then(|s| s.sub_highlight.as_ref())
119        {
120            if let (Some(fill_pl), Some(edge_pl), Some(sprite_pl)) = (
121                &self.resources.sub_highlight_fill_ldr_pipeline,
122                &self.resources.sub_highlight_edge_ldr_pipeline,
123                &self.resources.sub_highlight_sprite_ldr_pipeline,
124            ) {
125                if sub_hl.fill_vertex_count > 0 {
126                    render_pass.set_pipeline(fill_pl);
127                    render_pass.set_bind_group(0, camera_bg, &[]);
128                    render_pass.set_bind_group(1, &sub_hl.fill_bind_group, &[]);
129                    render_pass.set_vertex_buffer(0, sub_hl.fill_vertex_buf.slice(..));
130                    render_pass.draw(0..sub_hl.fill_vertex_count, 0..1);
131                }
132                if sub_hl.edge_segment_count > 0 {
133                    render_pass.set_pipeline(edge_pl);
134                    render_pass.set_bind_group(0, camera_bg, &[]);
135                    render_pass.set_bind_group(1, &sub_hl.edge_bind_group, &[]);
136                    render_pass.set_vertex_buffer(0, sub_hl.edge_vertex_buf.slice(..));
137                    render_pass.draw(0..6, 0..sub_hl.edge_segment_count);
138                }
139                if sub_hl.sprite_point_count > 0 {
140                    render_pass.set_pipeline(sprite_pl);
141                    render_pass.set_bind_group(0, camera_bg, &[]);
142                    render_pass.set_bind_group(1, &sub_hl.sprite_bind_group, &[]);
143                    render_pass.set_vertex_buffer(0, sub_hl.sprite_vertex_buf.slice(..));
144                    render_pass.draw(0..6, 0..sub_hl.sprite_point_count);
145                }
146            }
147        }
148        // Screen-space image overlays (always on top, no depth test).
149        if !self.screen_image_gpu_data.is_empty() {
150            if let Some(pipeline) = &self.resources.screen_image_pipeline {
151                render_pass.set_pipeline(pipeline);
152                for gpu in &self.screen_image_gpu_data {
153                    render_pass.set_bind_group(0, &gpu.bind_group, &[]);
154                    render_pass.draw(0..6, 0..1);
155                }
156            }
157        }
158        // SDF overlay shapes (drawn before rects and labels).
159        if let Some(ref sd) = self.overlay_shape_gpu_data {
160            if sd.vertex_count > 0 {
161                if let Some(pipeline) = &self.resources.overlay_shape_pipeline {
162                    if let Some(vbuf) = &sd.vertex_buf {
163                        render_pass.set_pipeline(pipeline);
164                        render_pass.set_vertex_buffer(0, vbuf.slice(..));
165                        render_pass.draw(0..sd.vertex_count, 0..1);
166                    }
167                }
168            }
169            if !sd.tex_batches.is_empty() {
170                if let Some(pipeline) = &self.resources.overlay_shape_tex_pipeline {
171                    render_pass.set_pipeline(pipeline);
172                    for batch in &sd.tex_batches {
173                        render_pass.set_bind_group(0, &batch.bind_group, &[]);
174                        render_pass.set_vertex_buffer(0, batch.vertex_buf.slice(..));
175                        render_pass.draw(0..batch.vertex_count, 0..1);
176                    }
177                }
178            }
179        }
180        // Overlay rects (drawn before labels so they act as backgrounds).
181        if let Some(ref rr) = self.overlay_rect_gpu_data {
182            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
183                render_pass.set_pipeline(pipeline);
184                render_pass.set_bind_group(0, &rr.bind_group, &[]);
185                render_pass.set_vertex_buffer(0, rr.vertex_buf.slice(..));
186                render_pass.draw(0..rr.vertex_count, 0..1);
187            }
188        }
189        // Overlay labels (always on top, after screen images).
190        if let Some(ref ld) = self.label_gpu_data {
191            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
192                render_pass.set_pipeline(pipeline);
193                render_pass.set_bind_group(0, &ld.bind_group, &[]);
194                render_pass.set_vertex_buffer(0, ld.vertex_buf.slice(..));
195                render_pass.draw(0..ld.vertex_count, 0..1);
196            }
197        }
198        // Scalar bars (drawn after labels).
199        if let Some(ref sb) = self.scalar_bar_gpu_data {
200            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
201                render_pass.set_pipeline(pipeline);
202                render_pass.set_bind_group(0, &sb.bind_group, &[]);
203                render_pass.set_vertex_buffer(0, sb.vertex_buf.slice(..));
204                render_pass.draw(0..sb.vertex_count, 0..1);
205            }
206        }
207        // Rulers (drawn after scalar bars).
208        if let Some(ref rd) = self.ruler_gpu_data {
209            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
210                render_pass.set_pipeline(pipeline);
211                render_pass.set_bind_group(0, &rd.bind_group, &[]);
212                render_pass.set_vertex_buffer(0, rd.vertex_buf.slice(..));
213                render_pass.draw(0..rd.vertex_count, 0..1);
214            }
215        }
216        // Loading bars (drawn after rulers).
217        if let Some(ref lb) = self.loading_bar_gpu_data {
218            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
219                render_pass.set_pipeline(pipeline);
220                render_pass.set_bind_group(0, &lb.bind_group, &[]);
221                render_pass.set_vertex_buffer(0, lb.vertex_buf.slice(..));
222                render_pass.draw(0..lb.vertex_count, 0..1);
223            }
224        }
225        // Overlay images (OverlayFrame, drawn last, no depth test).
226        if !self.overlay_image_gpu_data.is_empty() {
227            if let Some(pipeline) = &self.resources.screen_image_pipeline {
228                render_pass.set_pipeline(pipeline);
229                for gpu in &self.overlay_image_gpu_data {
230                    render_pass.set_bind_group(0, &gpu.bind_group, &[]);
231                    render_pass.draw(0..6, 0..1);
232                }
233            }
234        }
235        // Shadow atlas viewer overlay.
236        if frame.effects.show_shadow_atlas {
237            render_pass.set_pipeline(&self.resources.shadow_atlas_viewer_pipeline);
238            render_pass.set_bind_group(0, &self.resources.shadow_atlas_viewer_bg, &[]);
239            render_pass.draw(0..6, 0..1);
240        }
241    }
242
243    /// Render the scene into an intermediate dyn-res texture for the LDR callback
244    /// render path (e.g. eframe's `CallbackTrait`).
245    ///
246    /// Call from `CallbackTrait::prepare` after [`prepare`](Self::prepare), passing the
247    /// `egui_encoder`. If `current_render_scale < 1.0`, the full scene is drawn into a
248    /// scaled intermediate texture and `true` is returned. Call
249    /// [`paint_dyn_res_blit`](Self::paint_dyn_res_blit) from `CallbackTrait::paint`
250    /// instead of [`paint`](Self::paint).
251    ///
252    /// If scale is 1.0 or above, nothing is encoded and `false` is returned. Call
253    /// [`paint`](Self::paint) as normal.
254    ///
255    /// The `egui_encoder` is submitted before the surface render pass begins, so the
256    /// intermediate texture is fully written before the blit reads it.
257    pub(crate) fn prepare_ldr_dyn_res(
258        &mut self,
259        encoder: &mut wgpu::CommandEncoder,
260        device: &wgpu::Device,
261        frame: &FrameData,
262    ) -> bool {
263        if self.current_render_scale >= 1.0 - 0.001 {
264            return false;
265        }
266
267        let vp_idx = frame.camera.viewport_index;
268        let w = (frame.camera.viewport_size[0] as u32).max(1);
269        let h = (frame.camera.viewport_size[1] as u32).max(1);
270        let sw = ((w as f32 * self.current_render_scale) as u32).max(1);
271        let sh = ((h as f32 * self.current_render_scale) as u32).max(1);
272
273        self.ensure_dyn_res_target(device, vp_idx, [sw, sh], [w, h]);
274        self.resources.ensure_dyn_res_ds_pipeline(device);
275
276        let bg_colour = frame.viewport.background_colour.unwrap_or([
277            65.0 / 255.0,
278            65.0 / 255.0,
279            65.0 / 255.0,
280            1.0,
281        ]);
282
283        {
284            let slot = &self.viewport_slots[vp_idx];
285            let dr = slot.dyn_res.as_ref().unwrap();
286            let colour_view = &dr.colour_view;
287            let depth_view = &dr.depth_view;
288            let camera_bg = &slot.camera_bind_group;
289            let grid_bg = &slot.grid_bind_group;
290
291            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
292                label: Some("ldr_dyn_res_render_pass"),
293                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
294                    view: colour_view,
295                    resolve_target: None,
296                    ops: wgpu::Operations {
297                        load: wgpu::LoadOp::Clear(wgpu::Color {
298                            r: bg_colour[0] as f64,
299                            g: bg_colour[1] as f64,
300                            b: bg_colour[2] as f64,
301                            a: bg_colour[3] as f64,
302                        }),
303                        store: wgpu::StoreOp::Store,
304                    },
305                    depth_slice: None,
306                })],
307                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
308                    view: depth_view,
309                    depth_ops: Some(wgpu::Operations {
310                        load: wgpu::LoadOp::Clear(1.0),
311                        store: wgpu::StoreOp::Discard,
312                    }),
313                    stencil_ops: None,
314                }),
315                timestamp_writes: None,
316                occlusion_query_set: None,
317            });
318            emit_draw_calls!(
319                &self.resources,
320                &mut render_pass,
321                frame,
322                self.use_instancing,
323                &self.instanced_batches,
324                camera_bg,
325                grid_bg,
326                &self.compute_filter_results,
327                Some(slot),
328                &self.wireframe_bind_groups,
329                &self.per_item_object_bind_groups
330            );
331            emit_scivis_draw_calls!(
332                &self.resources,
333                &mut render_pass,
334                &self.point_cloud_gpu_data,
335                &self.glyph_gpu_data,
336                &self.polyline_gpu_data,
337                &self.volume_gpu_data,
338                &self.streamtube_gpu_data,
339                camera_bg,
340                &self.tube_gpu_data,
341                &self.image_slice_gpu_data,
342                &self.tensor_glyph_gpu_data,
343                &self.ribbon_gpu_data,
344                &self.volume_surface_slice_gpu_data,
345                &self.sprite_gpu_data,
346                &self.mesh_instance_gpu_data,
347                false
348            );
349            // TransparentVolumeMesh boundary wireframe overlay.
350            if !self.tvm_wireframe_draws.is_empty() {
351                if let Some(ref tvm_bg) = self.tvm_wireframe_bg {
352                    render_pass.set_bind_group(0, camera_bg, &[]);
353                    for mesh_id in &self.tvm_wireframe_draws {
354                        if let Some(mesh) = self.resources.mesh_store.get(*mesh_id) {
355                            render_pass.set_pipeline(&self.resources.wireframe_pipeline);
356                            render_pass.set_bind_group(
357                                2,
358                                &self.resources.deform.dummy_bind_group,
359                                &[],
360                            );
361                            render_pass.set_bind_group(1, tvm_bg, &[]);
362                            render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
363                            render_pass.set_index_buffer(
364                                mesh.edge_index_buffer.slice(..),
365                                wgpu::IndexFormat::Uint32,
366                            );
367                            render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
368                        }
369                    }
370                }
371            }
372            // Implicit surface.
373            if !self.implicit_gpu_data.is_empty() {
374                if let Some(ref dual) = self.resources.implicit_pipeline {
375                    render_pass.set_pipeline(dual.for_format(false));
376                    render_pass.set_bind_group(0, camera_bg, &[]);
377                    for gpu in &self.implicit_gpu_data {
378                        render_pass.set_bind_group(1, &gpu.bind_group, &[]);
379                        render_pass.draw(0..6, 0..1);
380                    }
381                }
382            }
383            // GPU marching cubes indirect draw.
384            if !self.mc_gpu_data.is_empty() {
385                if let Some(ref dual) = self.resources.mc_surface_pipeline {
386                    render_pass.set_pipeline(dual.for_format(false));
387                    render_pass.set_bind_group(0, camera_bg, &[]);
388                    for mc in &self.mc_gpu_data {
389                        let vol = &self.resources.mc_volumes[mc.volume_idx];
390                        render_pass.set_bind_group(1, &mc.render_bg, &[]);
391                        for slab in &vol.slabs {
392                            render_pass.set_vertex_buffer(0, slab.vertex_buf.slice(..));
393                            render_pass.draw_indirect(&slab.indirect_buf, 0);
394                        }
395                    }
396                }
397            }
398            // Outline composite after all scene content.
399            emit_outline_composite!(&self.resources, &mut render_pass, Some(slot));
400            // Sub-object highlight (LDR path).
401            if let Some(sub_hl) = slot.sub_highlight.as_ref() {
402                if let (Some(fill_pl), Some(edge_pl), Some(sprite_pl)) = (
403                    &self.resources.sub_highlight_fill_ldr_pipeline,
404                    &self.resources.sub_highlight_edge_ldr_pipeline,
405                    &self.resources.sub_highlight_sprite_ldr_pipeline,
406                ) {
407                    if sub_hl.fill_vertex_count > 0 {
408                        render_pass.set_pipeline(fill_pl);
409                        render_pass.set_bind_group(0, camera_bg, &[]);
410                        render_pass.set_bind_group(1, &sub_hl.fill_bind_group, &[]);
411                        render_pass.set_vertex_buffer(0, sub_hl.fill_vertex_buf.slice(..));
412                        render_pass.draw(0..sub_hl.fill_vertex_count, 0..1);
413                    }
414                    if sub_hl.edge_segment_count > 0 {
415                        render_pass.set_pipeline(edge_pl);
416                        render_pass.set_bind_group(0, camera_bg, &[]);
417                        render_pass.set_bind_group(1, &sub_hl.edge_bind_group, &[]);
418                        render_pass.set_vertex_buffer(0, sub_hl.edge_vertex_buf.slice(..));
419                        render_pass.draw(0..6, 0..sub_hl.edge_segment_count);
420                    }
421                    if sub_hl.sprite_point_count > 0 {
422                        render_pass.set_pipeline(sprite_pl);
423                        render_pass.set_bind_group(0, camera_bg, &[]);
424                        render_pass.set_bind_group(1, &sub_hl.sprite_bind_group, &[]);
425                        render_pass.set_vertex_buffer(0, sub_hl.sprite_vertex_buf.slice(..));
426                        render_pass.draw(0..6, 0..sub_hl.sprite_point_count);
427                    }
428                }
429            }
430            // Screen-space image overlays.
431            if !self.screen_image_gpu_data.is_empty() {
432                if let Some(pipeline) = &self.resources.screen_image_pipeline {
433                    render_pass.set_pipeline(pipeline);
434                    for gpu in &self.screen_image_gpu_data {
435                        render_pass.set_bind_group(0, &gpu.bind_group, &[]);
436                        render_pass.draw(0..6, 0..1);
437                    }
438                }
439            }
440            // SDF overlay shapes (drawn before rects and labels).
441            if let Some(ref sd) = self.overlay_shape_gpu_data {
442                if sd.vertex_count > 0 {
443                    if let Some(pipeline) = &self.resources.overlay_shape_pipeline {
444                        if let Some(vbuf) = &sd.vertex_buf {
445                            render_pass.set_pipeline(pipeline);
446                            render_pass.set_vertex_buffer(0, vbuf.slice(..));
447                            render_pass.draw(0..sd.vertex_count, 0..1);
448                        }
449                    }
450                }
451                if !sd.tex_batches.is_empty() {
452                    if let Some(pipeline) = &self.resources.overlay_shape_tex_pipeline {
453                        render_pass.set_pipeline(pipeline);
454                        for batch in &sd.tex_batches {
455                            render_pass.set_bind_group(0, &batch.bind_group, &[]);
456                            render_pass.set_vertex_buffer(0, batch.vertex_buf.slice(..));
457                            render_pass.draw(0..batch.vertex_count, 0..1);
458                        }
459                    }
460                }
461            }
462            // Overlay rects (drawn before labels so they act as backgrounds).
463            if let Some(ref rr) = self.overlay_rect_gpu_data {
464                if let Some(pipeline) = &self.resources.overlay_text_pipeline {
465                    render_pass.set_pipeline(pipeline);
466                    render_pass.set_bind_group(0, &rr.bind_group, &[]);
467                    render_pass.set_vertex_buffer(0, rr.vertex_buf.slice(..));
468                    render_pass.draw(0..rr.vertex_count, 0..1);
469                }
470            }
471            // Overlay labels.
472            if let Some(ref ld) = self.label_gpu_data {
473                if let Some(pipeline) = &self.resources.overlay_text_pipeline {
474                    render_pass.set_pipeline(pipeline);
475                    render_pass.set_bind_group(0, &ld.bind_group, &[]);
476                    render_pass.set_vertex_buffer(0, ld.vertex_buf.slice(..));
477                    render_pass.draw(0..ld.vertex_count, 0..1);
478                }
479            }
480            // Scalar bars.
481            if let Some(ref sb) = self.scalar_bar_gpu_data {
482                if let Some(pipeline) = &self.resources.overlay_text_pipeline {
483                    render_pass.set_pipeline(pipeline);
484                    render_pass.set_bind_group(0, &sb.bind_group, &[]);
485                    render_pass.set_vertex_buffer(0, sb.vertex_buf.slice(..));
486                    render_pass.draw(0..sb.vertex_count, 0..1);
487                }
488            }
489            // Rulers.
490            if let Some(ref rd) = self.ruler_gpu_data {
491                if let Some(pipeline) = &self.resources.overlay_text_pipeline {
492                    render_pass.set_pipeline(pipeline);
493                    render_pass.set_bind_group(0, &rd.bind_group, &[]);
494                    render_pass.set_vertex_buffer(0, rd.vertex_buf.slice(..));
495                    render_pass.draw(0..rd.vertex_count, 0..1);
496                }
497            }
498            // Loading bars.
499            if let Some(ref lb) = self.loading_bar_gpu_data {
500                if let Some(pipeline) = &self.resources.overlay_text_pipeline {
501                    render_pass.set_pipeline(pipeline);
502                    render_pass.set_bind_group(0, &lb.bind_group, &[]);
503                    render_pass.set_vertex_buffer(0, lb.vertex_buf.slice(..));
504                    render_pass.draw(0..lb.vertex_count, 0..1);
505                }
506            }
507            // Overlay images (drawn last).
508            if !self.overlay_image_gpu_data.is_empty() {
509                if let Some(pipeline) = &self.resources.screen_image_pipeline {
510                    render_pass.set_pipeline(pipeline);
511                    for gpu in &self.overlay_image_gpu_data {
512                        render_pass.set_bind_group(0, &gpu.bind_group, &[]);
513                        render_pass.draw(0..6, 0..1);
514                    }
515                }
516            }
517        }
518
519        true
520    }
521
522    /// Blit the dyn-res intermediate texture into the provided render pass.
523    ///
524    /// Call from `CallbackTrait::paint` when
525    /// [`prepare_ldr_dyn_res`](Self::prepare_ldr_dyn_res) returned `true` for the same
526    /// frame. Emits a fullscreen upscale quad into `render_pass`.
527    pub(crate) fn paint_dyn_res_blit<'rp>(
528        &self,
529        render_pass: &mut wgpu::RenderPass<'rp>,
530        frame: &FrameData,
531    ) {
532        let vp_idx = frame.camera.viewport_index;
533        if let Some(dr) = self
534            .viewport_slots
535            .get(vp_idx)
536            .and_then(|s| s.dyn_res.as_ref())
537        {
538            if let Some(pipeline) = &self.resources.dyn_res_upscale_ds_pipeline {
539                render_pass.set_pipeline(pipeline);
540                render_pass.set_bind_group(0, &dr.upscale_bind_group, &[]);
541                render_pass.draw(0..3, 0..1);
542            }
543        }
544    }
545
546    /// Run the full HDR pipeline (OIT, EDL, tone-map) for the eframe callback model.
547    ///
548    /// This is the HDR counterpart of
549    /// [`prepare_ldr_dyn_res`](Self::prepare_ldr_dyn_res) for use when
550    /// `frame.effects.post_process.enabled` is `true`.
551    ///
552    /// Internally this method:
553    /// 1. Calls [`prepare`](Self::prepare) to upload uniforms and run the shadow pass.
554    /// 2. Ensures a per-viewport intermediate texture at the viewport's native resolution.
555    /// 3. Calls the full render pipeline (including OIT and EDL) into that texture.
556    ///
557    /// The returned [`wgpu::CommandBuffer`] must be returned from
558    /// `CallbackTrait::prepare` so eframe submits it **before** the egui render pass.
559    ///
560    /// Call [`paint_hdr_blit`](Self::paint_hdr_blit) from `CallbackTrait::paint` to
561    /// composite the intermediate texture into the egui render pass.
562    pub(crate) fn prepare_hdr_callback(
563        &mut self,
564        device: &wgpu::Device,
565        queue: &wgpu::Queue,
566        frame: &FrameData,
567    ) -> wgpu::CommandBuffer {
568        self.prepare(device, queue, frame);
569
570        let vp_idx = frame.camera.viewport_index;
571        // Intermediate texture must be at physical pixel size so it matches the
572        // HDR depth buffer allocated inside render_frame_internal (which also
573        // uses physical pixels). Using logical size here produces a mismatch on
574        // hidpi displays between the colour attachment (this texture) and the
575        // depth attachment (hdr_depth_view) in the grid/overlay passes.
576        let ppp = frame.camera.pixels_per_point;
577        let w = (frame.camera.viewport_size[0] * ppp).round() as u32;
578        let h = (frame.camera.viewport_size[1] * ppp).round() as u32;
579
580        // Ensure the blit pipeline (required by create_hdr_callback_target).
581        self.resources.ensure_dyn_res_pipeline(device);
582        self.resources.ensure_dyn_res_ds_pipeline(device);
583
584        // Create or resize the per-viewport intermediate texture.
585        self.ensure_viewport_slot(device, vp_idx);
586        let needs_create = match self.viewport_slots[vp_idx].hdr_callback.as_ref() {
587            None => true,
588            Some(t) => t.size != [w, h],
589        };
590        if needs_create {
591            let target = self.resources.create_hdr_callback_target(device, [w, h]);
592            self.viewport_slots[vp_idx].hdr_callback = Some(target);
593        }
594
595        // Create a fresh TextureView from the stored Texture.
596        // This owned view does not borrow viewport_slots, allowing the subsequent
597        // mutable call to render_frame_internal without a borrow conflict.
598        let output_view = self.viewport_slots[vp_idx]
599            .hdr_callback
600            .as_ref()
601            .unwrap()
602            .texture
603            .create_view(&wgpu::TextureViewDescriptor::default());
604
605        self.render_frame_internal(device, queue, &output_view, vp_idx, frame)
606    }
607
608    /// HDR encode for a single viewport in the multi-viewport eframe callback model.
609    ///
610    /// Like [`prepare_hdr_callback`](Self::prepare_hdr_callback) but skips the internal
611    /// [`prepare`](Self::prepare) call. The caller must have already called
612    /// [`prepare_scene`](Self::prepare_scene) and [`prepare_viewport`](Self::prepare_viewport)
613    /// for `id` before invoking this.
614    ///
615    /// Multi-viewport HDR sequence:
616    /// 1. Call `prepare_scene` once.
617    /// 2. Call `prepare_viewport` for each viewport.
618    /// 3. Call this method for each viewport; collect the returned `CommandBuffer`s.
619    /// 4. Return them from `CallbackTrait::prepare`.
620    ///
621    /// Call [`paint_hdr_blit`](Self::paint_hdr_blit) for each viewport from
622    /// `CallbackTrait::paint` with the scissor/viewport rect set first.
623    pub(crate) fn prepare_hdr_callback_viewport(
624        &mut self,
625        device: &wgpu::Device,
626        queue: &wgpu::Queue,
627        id: ViewportId,
628        frame: &FrameData,
629    ) -> wgpu::CommandBuffer {
630        let vp_idx = id.0;
631        let ppp = frame.camera.pixels_per_point;
632        let w = (frame.camera.viewport_size[0] * ppp).round() as u32;
633        let h = (frame.camera.viewport_size[1] * ppp).round() as u32;
634
635        self.resources.ensure_dyn_res_pipeline(device);
636        self.resources.ensure_dyn_res_ds_pipeline(device);
637
638        self.ensure_viewport_slot(device, vp_idx);
639        let needs_create = match self.viewport_slots[vp_idx].hdr_callback.as_ref() {
640            None => true,
641            Some(t) => t.size != [w, h],
642        };
643        if needs_create {
644            let target = self.resources.create_hdr_callback_target(device, [w, h]);
645            self.viewport_slots[vp_idx].hdr_callback = Some(target);
646        }
647
648        let output_view = self.viewport_slots[vp_idx]
649            .hdr_callback
650            .as_ref()
651            .unwrap()
652            .texture
653            .create_view(&wgpu::TextureViewDescriptor::default());
654
655        self.render_frame_internal(device, queue, &output_view, vp_idx, frame)
656    }
657
658    /// Blit the HDR intermediate texture into the egui render pass.
659    ///
660    /// Call from `CallbackTrait::paint` after
661    /// [`prepare_hdr_callback`](Self::prepare_hdr_callback) has been called for the
662    /// same frame and viewport. Emits a fullscreen triangle into `render_pass`.
663    pub(crate) fn paint_hdr_blit<'rp>(
664        &self,
665        render_pass: &mut wgpu::RenderPass<'rp>,
666        frame: &FrameData,
667    ) {
668        let vp_idx = frame.camera.viewport_index;
669        if let Some(hc) = self
670            .viewport_slots
671            .get(vp_idx)
672            .and_then(|s| s.hdr_callback.as_ref())
673        {
674            if let Some(pipeline) = &self.resources.dyn_res_upscale_ds_pipeline {
675                render_pass.set_pipeline(pipeline);
676                render_pass.set_bind_group(0, &hc.blit_bind_group, &[]);
677                render_pass.draw(0..3, 0..1);
678            }
679        }
680        // Shadow atlas viewer overlay.
681        if frame.effects.show_shadow_atlas {
682            render_pass.set_pipeline(&self.resources.shadow_atlas_viewer_pipeline);
683            render_pass.set_bind_group(0, &self.resources.shadow_atlas_viewer_bg, &[]);
684            render_pass.draw(0..6, 0..1);
685        }
686    }
687
688    /// Like [`paint_hdr_blit`](Self::paint_hdr_blit) but for render passes without a
689    /// depth-stencil attachment. Use this when you create the blit render pass yourself
690    /// (e.g. winit) and omit the depth attachment.
691    pub(crate) fn paint_hdr_blit_no_ds<'rp>(
692        &self,
693        render_pass: &mut wgpu::RenderPass<'rp>,
694        frame: &FrameData,
695    ) {
696        let vp_idx = frame.camera.viewport_index;
697        if let Some(hc) = self
698            .viewport_slots
699            .get(vp_idx)
700            .and_then(|s| s.hdr_callback.as_ref())
701        {
702            if let Some(pipeline) = &self.resources.dyn_res_upscale_pipeline {
703                render_pass.set_pipeline(pipeline);
704                render_pass.set_bind_group(0, &hc.blit_bind_group, &[]);
705                render_pass.draw(0..3, 0..1);
706            }
707        }
708    }
709
710    /// Unified prepare step for the eframe `CallbackTrait::prepare` method.
711    ///
712    /// Replaces manual `prepare` + `prepare_ldr_dyn_res` or `prepare_hdr_callback`
713    /// calls. Dispatches internally based on `frame.effects.post_process.enabled`:
714    ///
715    /// - HDR path (`post_process.enabled = true`): runs the full HDR pipeline (OIT,
716    ///   EDL, tone-map) and returns the resulting `CommandBuffer` for eframe to
717    ///   submit before the egui render pass.
718    /// - LDR path: calls `prepare`, and if dynamic resolution is active, encodes the
719    ///   scene into a separate `CommandBuffer` (also submitted before the render
720    ///   pass). Returns an empty `Vec` when dyn-res is inactive.
721    ///
722    /// Call [`paint_callback`](Self::paint_callback) from `CallbackTrait::paint`.
723    pub(crate) fn prepare_callback(
724        &mut self,
725        device: &wgpu::Device,
726        queue: &wgpu::Queue,
727        frame: &FrameData,
728    ) -> Vec<wgpu::CommandBuffer> {
729        if frame.effects.post_process.enabled {
730            let cb = self.prepare_hdr_callback(device, queue, frame);
731            vec![cb]
732        } else {
733            self.prepare(device, queue, frame);
734            if self.current_render_scale < 1.0 - 0.001 {
735                let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
736                    label: Some("ldr_dyn_res_callback_encoder"),
737                });
738                self.prepare_ldr_dyn_res(&mut encoder, device, frame);
739                vec![encoder.finish()]
740            } else {
741                Vec::new()
742            }
743        }
744    }
745
746    /// Unified paint step for the eframe `CallbackTrait::paint` method.
747    ///
748    /// Call after [`prepare_callback`](Self::prepare_callback) for the same frame.
749    /// Dispatches internally to `paint_hdr_blit`, `paint_dyn_res_blit`, or `paint`
750    /// based on which path `prepare_callback` activated.
751    pub(crate) fn paint_callback<'rp>(
752        &self,
753        render_pass: &mut wgpu::RenderPass<'rp>,
754        frame: &FrameData,
755    ) {
756        let vp_idx = frame.camera.viewport_index;
757        if frame.effects.post_process.enabled {
758            if self
759                .viewport_slots
760                .get(vp_idx)
761                .and_then(|s| s.hdr_callback.as_ref())
762                .is_some()
763            {
764                self.paint_hdr_blit(render_pass, frame);
765                return;
766            }
767        }
768        if self.current_render_scale < 1.0 - 0.001
769            && self
770                .viewport_slots
771                .get(vp_idx)
772                .and_then(|s| s.dyn_res.as_ref())
773                .is_some()
774        {
775            self.paint_dyn_res_blit(render_pass, frame);
776        } else {
777            self.paint_to(render_pass, frame);
778        }
779    }
780
781    /// High-level HDR render for a single viewport identified by `id`.
782    ///
783    /// Unlike [`render`](Self::render), this method does **not** call
784    /// [`prepare`](Self::prepare) internally.  The caller must have already called
785    /// [`prepare_scene`](Self::prepare_scene) and
786    /// [`prepare_viewport`](Self::prepare_viewport) for `id` before invoking this.
787    ///
788    /// This is the right entry point for multi-viewport frames:
789    /// 1. Call `prepare_scene` once.
790    /// 2. Call `prepare_viewport` for each viewport.
791    /// 3. Call `render_viewport` for each viewport with its own `output_view`.
792    ///
793    /// Returns a [`wgpu::CommandBuffer`] ready to submit.
794    pub(crate) fn render_viewport(
795        &mut self,
796        device: &wgpu::Device,
797        queue: &wgpu::Queue,
798        output_view: &wgpu::TextureView,
799        id: ViewportId,
800        frame: &FrameData,
801    ) -> wgpu::CommandBuffer {
802        self.render_frame_internal(device, queue, output_view, id.0, frame)
803    }
804
805    /// High-level HDR render method. Handles the full post-processing pipeline:
806    /// scene -> HDR texture -> (bloom) -> (SSAO) -> tone map -> output_view.
807    ///
808    /// When `frame.post_process.enabled` is false, falls back to a simple LDR render
809    /// pass targeting `output_view` directly.
810    ///
811    /// Returns a `CommandBuffer` ready to submit.
812    pub(crate) fn render(
813        &mut self,
814        device: &wgpu::Device,
815        queue: &wgpu::Queue,
816        output_view: &wgpu::TextureView,
817        frame: &FrameData,
818    ) -> wgpu::CommandBuffer {
819        // Always run prepare() to upload uniforms and run the shadow pass.
820        self.prepare(device, queue, frame);
821        self.render_frame_internal(
822            device,
823            queue,
824            output_view,
825            frame.camera.viewport_index,
826            frame,
827        )
828    }
829
830    /// Render-only path shared by `render()` and `render_viewport()`.
831    ///
832    /// `vp_idx` selects the per-viewport slot to use for camera/HDR state,
833    /// independent of `frame.camera.viewport_index`.
834    fn render_frame_internal(
835        &mut self,
836        device: &wgpu::Device,
837        queue: &wgpu::Queue,
838        output_view: &wgpu::TextureView,
839        vp_idx: usize,
840        frame: &FrameData,
841    ) -> wgpu::CommandBuffer {
842        // Read scene items from the surface submission, then extend with the
843        // boundary draws contributed by opaque volume meshes (see the matching
844        // construction in `prepare.rs`).
845        let scene_items_owned: Vec<SceneRenderItem> = {
846            let surfaces = match &frame.scene.surfaces {
847                SurfaceSubmission::Flat(items) => items.as_ref(),
848            };
849            let extra = frame
850                .scene
851                .volume_meshes
852                .iter()
853                .filter(|item| item.transparency.is_none())
854                .map(|item| item.to_render_item());
855            surfaces.iter().cloned().chain(extra).collect()
856        };
857        let scene_items: &[SceneRenderItem] = &scene_items_owned;
858
859        let bg_colour = frame.viewport.background_colour.unwrap_or([
860            65.0 / 255.0,
861            65.0 / 255.0,
862            65.0 / 255.0,
863            1.0,
864        ]);
865        let ppp = frame.camera.pixels_per_point;
866        let w = (frame.camera.viewport_size[0] * ppp).round() as u32;
867        let h = (frame.camera.viewport_size[1] * ppp).round() as u32;
868
869        // Ensure per-viewport HDR targets. Provides a depth buffer for both LDR and HDR paths.
870        let ssaa_factor = frame.effects.post_process.ssaa_factor.max(1);
871        self.ensure_viewport_hdr(
872            device,
873            queue,
874            vp_idx,
875            w.max(1),
876            h.max(1),
877            ssaa_factor,
878            self.current_render_scale,
879        );
880
881        // Lazy-initialize GPU timestamp resources on first render call when supported.
882        if self.ts_query_set.is_none()
883            && device.features().contains(wgpu::Features::TIMESTAMP_QUERY)
884        {
885            self.ts_query_set = Some(device.create_query_set(&wgpu::QuerySetDescriptor {
886                label: Some("ts_query_set"),
887                ty: wgpu::QueryType::Timestamp,
888                count: 2,
889            }));
890            self.ts_resolve_buf = Some(device.create_buffer(&wgpu::BufferDescriptor {
891                label: Some("ts_resolve_buf"),
892                size: 16,
893                usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
894                mapped_at_creation: false,
895            }));
896            self.ts_staging_buf = Some(device.create_buffer(&wgpu::BufferDescriptor {
897                label: Some("ts_staging_buf"),
898                size: 16,
899                usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
900                mapped_at_creation: false,
901            }));
902            self.ts_period = queue.get_timestamp_period();
903        }
904
905        if !frame.effects.post_process.enabled {
906            // LDR fallback. When dynamic resolution is active and render_scale < 1.0,
907            // draw into a scaled intermediate texture and upscale-blit to output_view.
908            // Otherwise render directly to output_view.
909            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
910                label: Some("ldr_encoder"),
911            });
912
913            let use_dyn_res = self.current_render_scale < 1.0 - 0.001;
914            let needs_blur = self.has_backdrop_blur_shapes();
915
916            if use_dyn_res {
917                let sw = ((w as f32 * self.current_render_scale) as u32).max(1);
918                let sh = ((h as f32 * self.current_render_scale) as u32).max(1);
919                self.ensure_dyn_res_target(device, vp_idx, [sw, sh], [w.max(1), h.max(1)]);
920            }
921
922            // When blur backdrops are needed and we'd otherwise render directly
923            // to the surface (no dyn_res), force an intermediate so it can be
924            // sampled for the blur passes.
925            if needs_blur && !use_dyn_res {
926                self.ensure_backdrop_blur_state(device, w.max(1), h.max(1));
927            }
928
929            {
930                let slot = &self.viewport_slots[vp_idx];
931                let slot_hdr = slot.hdr.as_ref().expect(
932                    "HDR state missing in LDR path; ensure_viewport_hdr must have been called",
933                );
934                let camera_bg = &slot.camera_bind_group;
935                let grid_bg = &slot.grid_bind_group;
936                // Choose render target: dyn_res intermediate, backdrop intermediate, or output_view.
937                let (scene_colour_view, scene_depth_view): (
938                    &wgpu::TextureView,
939                    &wgpu::TextureView,
940                ) = if use_dyn_res {
941                    let dr = slot.dyn_res.as_ref().unwrap();
942                    (&dr.colour_view, &dr.depth_view)
943                } else if needs_blur {
944                    let bs = self.backdrop_blur_state.as_ref().unwrap();
945                    (&bs.intermediate_view, &slot_hdr.outline_depth_view)
946                } else {
947                    (output_view, &slot_hdr.outline_depth_view)
948                };
949                let ts_writes =
950                    self.ts_query_set
951                        .as_ref()
952                        .map(|qs| wgpu::RenderPassTimestampWrites {
953                            query_set: qs,
954                            beginning_of_pass_write_index: Some(0),
955                            end_of_pass_write_index: Some(1),
956                        });
957                let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
958                    label: Some("ldr_render_pass"),
959                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
960                        view: scene_colour_view,
961                        resolve_target: None,
962                        ops: wgpu::Operations {
963                            load: wgpu::LoadOp::Clear(wgpu::Color {
964                                r: bg_colour[0] as f64,
965                                g: bg_colour[1] as f64,
966                                b: bg_colour[2] as f64,
967                                a: bg_colour[3] as f64,
968                            }),
969                            store: wgpu::StoreOp::Store,
970                        },
971                        depth_slice: None,
972                    })],
973                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
974                        view: scene_depth_view,
975                        depth_ops: Some(wgpu::Operations {
976                            load: wgpu::LoadOp::Clear(1.0),
977                            store: wgpu::StoreOp::Discard,
978                        }),
979                        stencil_ops: None,
980                    }),
981                    timestamp_writes: ts_writes,
982                    occlusion_query_set: None,
983                });
984                emit_draw_calls!(
985                    &self.resources,
986                    &mut render_pass,
987                    frame,
988                    self.use_instancing,
989                    &self.instanced_batches,
990                    camera_bg,
991                    grid_bg,
992                    &self.compute_filter_results,
993                    Some(slot),
994                    &self.wireframe_bind_groups,
995                    &self.per_item_object_bind_groups
996                );
997                emit_scivis_draw_calls!(
998                    &self.resources,
999                    &mut render_pass,
1000                    &self.point_cloud_gpu_data,
1001                    &self.glyph_gpu_data,
1002                    &self.polyline_gpu_data,
1003                    &self.volume_gpu_data,
1004                    &self.streamtube_gpu_data,
1005                    camera_bg,
1006                    &self.tube_gpu_data,
1007                    &self.image_slice_gpu_data,
1008                    &self.tensor_glyph_gpu_data,
1009                    &self.ribbon_gpu_data,
1010                    &self.volume_surface_slice_gpu_data,
1011                    &self.sprite_gpu_data,
1012                    &self.mesh_instance_gpu_data,
1013                    false
1014                );
1015                // TransparentVolumeMesh boundary wireframe overlay.
1016                if !self.tvm_wireframe_draws.is_empty() {
1017                    if let Some(ref tvm_bg) = self.tvm_wireframe_bg {
1018                        render_pass.set_bind_group(0, camera_bg, &[]);
1019                        for mesh_id in &self.tvm_wireframe_draws {
1020                            if let Some(mesh) = self.resources.mesh_store.get(*mesh_id) {
1021                                render_pass.set_pipeline(&self.resources.wireframe_pipeline);
1022                                render_pass.set_bind_group(
1023                                    2,
1024                                    &self.resources.deform.dummy_bind_group,
1025                                    &[],
1026                                );
1027                                render_pass.set_bind_group(1, tvm_bg, &[]);
1028                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1029                                render_pass.set_index_buffer(
1030                                    mesh.edge_index_buffer.slice(..),
1031                                    wgpu::IndexFormat::Uint32,
1032                                );
1033                                render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
1034                            }
1035                        }
1036                    }
1037                }
1038                // GPU implicit surface.
1039                if !self.implicit_gpu_data.is_empty() {
1040                    if let Some(ref dual) = self.resources.implicit_pipeline {
1041                        render_pass.set_pipeline(dual.for_format(false));
1042                        render_pass.set_bind_group(0, camera_bg, &[]);
1043                        for gpu in &self.implicit_gpu_data {
1044                            render_pass.set_bind_group(1, &gpu.bind_group, &[]);
1045                            render_pass.draw(0..6, 0..1);
1046                        }
1047                    }
1048                }
1049                // GPU marching cubes indirect draw.
1050                if !self.mc_gpu_data.is_empty() {
1051                    if let Some(ref dual) = self.resources.mc_surface_pipeline {
1052                        render_pass.set_pipeline(dual.for_format(false));
1053                        render_pass.set_bind_group(0, camera_bg, &[]);
1054                        for mc in &self.mc_gpu_data {
1055                            let vol = &self.resources.mc_volumes[mc.volume_idx];
1056                            render_pass.set_bind_group(1, &mc.render_bg, &[]);
1057                            for slab in &vol.slabs {
1058                                render_pass.set_vertex_buffer(0, slab.vertex_buf.slice(..));
1059                                render_pass.draw_indirect(&slab.indirect_buf, 0);
1060                            }
1061                        }
1062                    }
1063                }
1064                // Outline composite after all scene content.
1065                emit_outline_composite!(&self.resources, &mut render_pass, Some(slot));
1066                // Screen-space image overlays.
1067                // Regular items drawn with depth_compare: Always (always on top).
1068                // Depth-composite items drawn with depth_compare: LessEqual (occluded by
1069                // scene geometry whose depth was already written to the depth attachment).
1070                if !self.screen_image_gpu_data.is_empty() {
1071                    if let Some(overlay_pipeline) = &self.resources.screen_image_pipeline {
1072                        let dc_pipeline = self.resources.screen_image_dc_pipeline.as_ref();
1073                        for gpu in &self.screen_image_gpu_data {
1074                            if let (Some(dc_bg), Some(dc_pipe)) =
1075                                (&gpu.depth_bind_group, dc_pipeline)
1076                            {
1077                                render_pass.set_pipeline(dc_pipe);
1078                                render_pass.set_bind_group(0, dc_bg, &[]);
1079                            } else {
1080                                render_pass.set_pipeline(overlay_pipeline);
1081                                render_pass.set_bind_group(0, &gpu.bind_group, &[]);
1082                            }
1083                            render_pass.draw(0..6, 0..1);
1084                        }
1085                    }
1086                }
1087                // When blur backdrops are needed, skip overlays here. They'll
1088                // be drawn in a second pass after the blur is applied.
1089                if !needs_blur {
1090                    // SDF overlay shapes (LDR fallback).
1091                    if let Some(ref sd) = self.overlay_shape_gpu_data {
1092                        if sd.vertex_count > 0 {
1093                            if let Some(pipeline) = &self.resources.overlay_shape_pipeline {
1094                                if let Some(vbuf) = &sd.vertex_buf {
1095                                    render_pass.set_pipeline(pipeline);
1096                                    render_pass.set_vertex_buffer(0, vbuf.slice(..));
1097                                    render_pass.draw(0..sd.vertex_count, 0..1);
1098                                }
1099                            }
1100                        }
1101                        if !sd.tex_batches.is_empty() {
1102                            if let Some(pipeline) = &self.resources.overlay_shape_tex_pipeline {
1103                                render_pass.set_pipeline(pipeline);
1104                                for batch in &sd.tex_batches {
1105                                    render_pass.set_bind_group(0, &batch.bind_group, &[]);
1106                                    render_pass.set_vertex_buffer(0, batch.vertex_buf.slice(..));
1107                                    render_pass.draw(0..batch.vertex_count, 0..1);
1108                                }
1109                            }
1110                        }
1111                    }
1112                    // Overlay rects (LDR fallback).
1113                    if let Some(ref rr) = self.overlay_rect_gpu_data {
1114                        if let Some(pipeline) = &self.resources.overlay_text_pipeline {
1115                            render_pass.set_pipeline(pipeline);
1116                            render_pass.set_bind_group(0, &rr.bind_group, &[]);
1117                            render_pass.set_vertex_buffer(0, rr.vertex_buf.slice(..));
1118                            render_pass.draw(0..rr.vertex_count, 0..1);
1119                        }
1120                    }
1121                    // Overlay labels (LDR fallback: inside the same render pass).
1122                    if let Some(ref ld) = self.label_gpu_data {
1123                        if let Some(pipeline) = &self.resources.overlay_text_pipeline {
1124                            render_pass.set_pipeline(pipeline);
1125                            render_pass.set_bind_group(0, &ld.bind_group, &[]);
1126                            render_pass.set_vertex_buffer(0, ld.vertex_buf.slice(..));
1127                            render_pass.draw(0..ld.vertex_count, 0..1);
1128                        }
1129                    }
1130                    // Scalar bars (LDR fallback).
1131                    if let Some(ref sb) = self.scalar_bar_gpu_data {
1132                        if let Some(pipeline) = &self.resources.overlay_text_pipeline {
1133                            render_pass.set_pipeline(pipeline);
1134                            render_pass.set_bind_group(0, &sb.bind_group, &[]);
1135                            render_pass.set_vertex_buffer(0, sb.vertex_buf.slice(..));
1136                            render_pass.draw(0..sb.vertex_count, 0..1);
1137                        }
1138                    }
1139                    // Rulers (LDR fallback).
1140                    if let Some(ref rd) = self.ruler_gpu_data {
1141                        if let Some(pipeline) = &self.resources.overlay_text_pipeline {
1142                            render_pass.set_pipeline(pipeline);
1143                            render_pass.set_bind_group(0, &rd.bind_group, &[]);
1144                            render_pass.set_vertex_buffer(0, rd.vertex_buf.slice(..));
1145                            render_pass.draw(0..rd.vertex_count, 0..1);
1146                        }
1147                    }
1148                    // Overlay images (OverlayFrame, LDR fallback, drawn last).
1149                    if !self.overlay_image_gpu_data.is_empty() {
1150                        if let Some(pipeline) = &self.resources.screen_image_pipeline {
1151                            render_pass.set_pipeline(pipeline);
1152                            for gpu in &self.overlay_image_gpu_data {
1153                                render_pass.set_bind_group(0, &gpu.bind_group, &[]);
1154                                render_pass.draw(0..6, 0..1);
1155                            }
1156                        }
1157                    }
1158                }
1159            }
1160            // -- End of scene render pass (dropped above). ---
1161
1162            // Backdrop blur: capture scene, run blur, then draw overlays in a
1163            // second render pass so blur shapes can sample the blurred result.
1164            if needs_blur {
1165                let spread = self
1166                    .overlay_shape_gpu_data
1167                    .as_ref()
1168                    .map(|d| d.max_blur_radius)
1169                    .unwrap_or(1.0);
1170                let blur_bg = {
1171                    let source = if use_dyn_res {
1172                        &self.viewport_slots[vp_idx]
1173                            .dyn_res
1174                            .as_ref()
1175                            .unwrap()
1176                            .colour_view
1177                    } else {
1178                        &self.backdrop_blur_state.as_ref().unwrap().intermediate_view
1179                    };
1180                    self.run_backdrop_blur(&mut encoder, device, queue, source, spread)
1181                };
1182
1183                // Second render pass for overlays (Load to preserve scene content).
1184                let slot = &self.viewport_slots[vp_idx];
1185                let slot_hdr = slot.hdr.as_ref().unwrap();
1186                let overlay_colour_view: &wgpu::TextureView = if use_dyn_res {
1187                    &slot.dyn_res.as_ref().unwrap().colour_view
1188                } else {
1189                    &self.backdrop_blur_state.as_ref().unwrap().intermediate_view
1190                };
1191                let overlay_depth_view: &wgpu::TextureView = if use_dyn_res {
1192                    &slot.dyn_res.as_ref().unwrap().depth_view
1193                } else {
1194                    &slot_hdr.outline_depth_view
1195                };
1196                {
1197                    let mut overlay_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1198                        label: Some("ldr_overlay_blur_pass"),
1199                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1200                            view: overlay_colour_view,
1201                            resolve_target: None,
1202                            ops: wgpu::Operations {
1203                                load: wgpu::LoadOp::Load,
1204                                store: wgpu::StoreOp::Store,
1205                            },
1206                            depth_slice: None,
1207                        })],
1208                        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1209                            view: overlay_depth_view,
1210                            depth_ops: Some(wgpu::Operations {
1211                                load: wgpu::LoadOp::Load,
1212                                store: wgpu::StoreOp::Discard,
1213                            }),
1214                            stencil_ops: None,
1215                        }),
1216                        timestamp_writes: None,
1217                        occlusion_query_set: None,
1218                    });
1219                    // Draw blur backdrop shapes first.
1220                    self.draw_blur_shapes(&mut overlay_pass, &blur_bg);
1221                    // Then normal shapes.
1222                    if let Some(ref sd) = self.overlay_shape_gpu_data {
1223                        if sd.vertex_count > 0 {
1224                            if let Some(pipeline) = &self.resources.overlay_shape_pipeline {
1225                                if let Some(vbuf) = &sd.vertex_buf {
1226                                    overlay_pass.set_pipeline(pipeline);
1227                                    overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
1228                                    overlay_pass.draw(0..sd.vertex_count, 0..1);
1229                                }
1230                            }
1231                        }
1232                        if !sd.tex_batches.is_empty() {
1233                            if let Some(pipeline) = &self.resources.overlay_shape_tex_pipeline {
1234                                overlay_pass.set_pipeline(pipeline);
1235                                for batch in &sd.tex_batches {
1236                                    overlay_pass.set_bind_group(0, &batch.bind_group, &[]);
1237                                    overlay_pass.set_vertex_buffer(0, batch.vertex_buf.slice(..));
1238                                    overlay_pass.draw(0..batch.vertex_count, 0..1);
1239                                }
1240                            }
1241                        }
1242                    }
1243                    if let Some(pipeline) = &self.resources.overlay_text_pipeline {
1244                        if let Some(ref rr) = self.overlay_rect_gpu_data {
1245                            overlay_pass.set_pipeline(pipeline);
1246                            overlay_pass.set_bind_group(0, &rr.bind_group, &[]);
1247                            overlay_pass.set_vertex_buffer(0, rr.vertex_buf.slice(..));
1248                            overlay_pass.draw(0..rr.vertex_count, 0..1);
1249                        }
1250                        if let Some(ref ld) = self.label_gpu_data {
1251                            overlay_pass.set_pipeline(pipeline);
1252                            overlay_pass.set_bind_group(0, &ld.bind_group, &[]);
1253                            overlay_pass.set_vertex_buffer(0, ld.vertex_buf.slice(..));
1254                            overlay_pass.draw(0..ld.vertex_count, 0..1);
1255                        }
1256                        if let Some(ref sb) = self.scalar_bar_gpu_data {
1257                            overlay_pass.set_pipeline(pipeline);
1258                            overlay_pass.set_bind_group(0, &sb.bind_group, &[]);
1259                            overlay_pass.set_vertex_buffer(0, sb.vertex_buf.slice(..));
1260                            overlay_pass.draw(0..sb.vertex_count, 0..1);
1261                        }
1262                        if let Some(ref rd) = self.ruler_gpu_data {
1263                            overlay_pass.set_pipeline(pipeline);
1264                            overlay_pass.set_bind_group(0, &rd.bind_group, &[]);
1265                            overlay_pass.set_vertex_buffer(0, rd.vertex_buf.slice(..));
1266                            overlay_pass.draw(0..rd.vertex_count, 0..1);
1267                        }
1268                    }
1269                    if !self.overlay_image_gpu_data.is_empty() {
1270                        if let Some(pipeline) = &self.resources.screen_image_pipeline {
1271                            overlay_pass.set_pipeline(pipeline);
1272                            for gpu in &self.overlay_image_gpu_data {
1273                                overlay_pass.set_bind_group(0, &gpu.bind_group, &[]);
1274                                overlay_pass.draw(0..6, 0..1);
1275                            }
1276                        }
1277                    }
1278                }
1279            }
1280
1281            // Resolve timestamp queries -> staging buffer.
1282            if let (Some(qs), Some(res_buf), Some(stg_buf)) = (
1283                self.ts_query_set.as_ref(),
1284                self.ts_resolve_buf.as_ref(),
1285                self.ts_staging_buf.as_ref(),
1286            ) {
1287                encoder.resolve_query_set(qs, 0..2, res_buf, 0);
1288                encoder.copy_buffer_to_buffer(res_buf, 0, stg_buf, 0, 16);
1289                self.ts_needs_readback = true;
1290            }
1291
1292            // Upscale blit from dyn_res intermediate to output_view.
1293            if use_dyn_res {
1294                let upscale_bg = &self.viewport_slots[vp_idx]
1295                    .dyn_res
1296                    .as_ref()
1297                    .unwrap()
1298                    .upscale_bind_group;
1299                let mut upscale_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1300                    label: Some("dyn_res_upscale_pass"),
1301                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1302                        view: output_view,
1303                        resolve_target: None,
1304                        ops: wgpu::Operations {
1305                            load: wgpu::LoadOp::Load,
1306                            store: wgpu::StoreOp::Store,
1307                        },
1308                        depth_slice: None,
1309                    })],
1310                    depth_stencil_attachment: None,
1311                    timestamp_writes: None,
1312                    occlusion_query_set: None,
1313                });
1314                if let Some(pipeline) = &self.resources.dyn_res_upscale_pipeline {
1315                    upscale_pass.set_pipeline(pipeline);
1316                    upscale_pass.set_bind_group(0, upscale_bg, &[]);
1317                    upscale_pass.draw(0..3, 0..1);
1318                }
1319            } else if needs_blur {
1320                // Blit backdrop intermediate to the output surface.
1321                let bs = self.backdrop_blur_state.as_ref().unwrap();
1322                let blit_bgl = self.resources.dyn_res_upscale_bgl.as_ref().unwrap();
1323                let blit_sampler = self.resources.dyn_res_linear_sampler.as_ref().unwrap();
1324                let blit_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1325                    label: Some("backdrop_blit_bg"),
1326                    layout: blit_bgl,
1327                    entries: &[
1328                        wgpu::BindGroupEntry {
1329                            binding: 0,
1330                            resource: wgpu::BindingResource::TextureView(&bs.intermediate_view),
1331                        },
1332                        wgpu::BindGroupEntry {
1333                            binding: 1,
1334                            resource: wgpu::BindingResource::Sampler(blit_sampler),
1335                        },
1336                    ],
1337                });
1338                let mut blit_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1339                    label: Some("backdrop_blit_pass"),
1340                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1341                        view: output_view,
1342                        resolve_target: None,
1343                        ops: wgpu::Operations {
1344                            load: wgpu::LoadOp::Load,
1345                            store: wgpu::StoreOp::Store,
1346                        },
1347                        depth_slice: None,
1348                    })],
1349                    depth_stencil_attachment: None,
1350                    timestamp_writes: None,
1351                    occlusion_query_set: None,
1352                });
1353                if let Some(pipeline) = &self.resources.dyn_res_upscale_pipeline {
1354                    blit_pass.set_pipeline(pipeline);
1355                    blit_pass.set_bind_group(0, &blit_bg, &[]);
1356                    blit_pass.draw(0..3, 0..1);
1357                }
1358            }
1359
1360            return encoder.finish();
1361        }
1362
1363        // HDR path.
1364        let pp = &frame.effects.post_process;
1365
1366        let hdr_clear_rgb = [
1367            bg_colour[0].powf(2.2),
1368            bg_colour[1].powf(2.2),
1369            bg_colour[2].powf(2.2),
1370        ];
1371
1372        // Upload tone map uniform into the per-viewport buffer.
1373        let mode = match pp.tone_mapping {
1374            crate::renderer::ToneMapping::Reinhard => 0u32,
1375            crate::renderer::ToneMapping::Aces => 1u32,
1376            crate::renderer::ToneMapping::KhronosNeutral => 2u32,
1377        };
1378        let tm_uniform = crate::resources::ToneMapUniform {
1379            exposure: pp.exposure,
1380            mode,
1381            bloom_enabled: if pp.bloom { 1 } else { 0 },
1382            ssao_enabled: if pp.ssao { 1 } else { 0 },
1383            contact_shadows_enabled: if pp.contact_shadows { 1 } else { 0 },
1384            edl_enabled: if pp.edl_enabled { 1 } else { 0 },
1385            edl_radius: pp.edl_radius,
1386            edl_strength: pp.edl_strength,
1387            background_colour: bg_colour,
1388            near_plane: frame.camera.render_camera.near,
1389            far_plane: frame.camera.render_camera.far,
1390            lic_enabled: if scene_items
1391                .iter()
1392                .any(|i| i.lic.is_some() && !i.settings.hidden)
1393            {
1394                1
1395            } else {
1396                0
1397            },
1398            lic_strength: scene_items
1399                .iter()
1400                .filter(|i| !i.settings.hidden)
1401                .find_map(|i| i.lic.as_ref().map(|l| l.config.strength))
1402                .unwrap_or(0.5),
1403        };
1404        {
1405            let hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
1406            queue.write_buffer(
1407                &hdr.tone_map_uniform_buf,
1408                0,
1409                bytemuck::cast_slice(&[tm_uniform]),
1410            );
1411
1412            // Upload SSAO uniform if needed.
1413            if pp.ssao {
1414                let proj = frame.camera.render_camera.projection;
1415                let inv_proj = proj.inverse();
1416                let ssao_uniform = crate::resources::SsaoUniform {
1417                    inv_proj: inv_proj.to_cols_array_2d(),
1418                    proj: proj.to_cols_array_2d(),
1419                    radius: 0.5,
1420                    bias: 0.025,
1421                    _pad: [0.0; 2],
1422                };
1423                queue.write_buffer(
1424                    &hdr.ssao_uniform_buf,
1425                    0,
1426                    bytemuck::cast_slice(&[ssao_uniform]),
1427                );
1428            }
1429
1430            // Upload contact shadow uniform if needed.
1431            if pp.contact_shadows {
1432                let proj = frame.camera.render_camera.projection;
1433                let inv_proj = proj.inverse();
1434                let light_dir_world: glam::Vec3 =
1435                    if let Some(l) = frame.effects.lighting.lights.first() {
1436                        match l.kind {
1437                            LightKind::Directional { direction } => {
1438                                glam::Vec3::from(direction).normalize()
1439                            }
1440                            LightKind::Spot { direction, .. } => {
1441                                // Spot::direction is the shining direction
1442                                // (light -> scene); the march needs the
1443                                // surface -> light direction, so negate.
1444                                -glam::Vec3::from(direction).normalize()
1445                            }
1446                            _ => glam::Vec3::new(0.0, -1.0, 0.0),
1447                        }
1448                    } else {
1449                        glam::Vec3::new(0.0, -1.0, 0.0)
1450                    };
1451                let view = frame.camera.render_camera.view;
1452                let light_dir_view = view.transform_vector3(light_dir_world).normalize();
1453                let world_up_view = view.transform_vector3(glam::Vec3::Z).normalize();
1454                let cs_uniform = crate::resources::ContactShadowUniform {
1455                    inv_proj: inv_proj.to_cols_array_2d(),
1456                    proj: proj.to_cols_array_2d(),
1457                    light_dir_view: [light_dir_view.x, light_dir_view.y, light_dir_view.z, 0.0],
1458                    world_up_view: [world_up_view.x, world_up_view.y, world_up_view.z, 0.0],
1459                    params: [
1460                        pp.contact_shadow_max_distance,
1461                        pp.contact_shadow_steps as f32,
1462                        pp.contact_shadow_thickness,
1463                        0.0,
1464                    ],
1465                };
1466                queue.write_buffer(
1467                    &hdr.contact_shadow_uniform_buf,
1468                    0,
1469                    bytemuck::cast_slice(&[cs_uniform]),
1470                );
1471            }
1472
1473            // Upload bloom uniform if needed.
1474            if pp.bloom {
1475                let bloom_u = crate::resources::BloomUniform {
1476                    threshold: pp.bloom_threshold,
1477                    intensity: pp.bloom_intensity,
1478                    horizontal: 0,
1479                    _pad: 0,
1480                };
1481                queue.write_buffer(&hdr.bloom_uniform_buf, 0, bytemuck::cast_slice(&[bloom_u]));
1482            }
1483        }
1484
1485        // Upload DoF uniform when enabled.
1486        if pp.dof_enabled {
1487            let (w, h) = {
1488                let hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
1489                (hdr.scene_size[0] as f32, hdr.scene_size[1] as f32)
1490            };
1491            let dof_uniform = crate::resources::DofUniform {
1492                focal_distance: pp.dof_focal_distance,
1493                focal_range: pp.dof_focal_range,
1494                max_blur_radius: pp.dof_max_blur_radius,
1495                near_plane: frame.camera.render_camera.near,
1496                far_plane: frame.camera.render_camera.far,
1497                viewport_width: w,
1498                viewport_height: h,
1499                _pad: 0.0,
1500            };
1501            let hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
1502            queue.write_buffer(
1503                &hdr.dof_uniform_buf,
1504                0,
1505                bytemuck::cast_slice(&[dof_uniform]),
1506            );
1507        }
1508
1509        // Rebuild tone-map bind group with correct bloom/AO/DoF texture views.
1510        {
1511            let hdr = self.viewport_slots[vp_idx].hdr.as_mut().unwrap();
1512            self.resources.rebuild_tone_map_bind_group(
1513                device,
1514                hdr,
1515                pp.bloom,
1516                pp.ssao,
1517                pp.contact_shadows,
1518                scene_items
1519                    .iter()
1520                    .any(|i| i.lic.is_some() && !i.settings.hidden),
1521                pp.dof_enabled,
1522            );
1523        }
1524
1525        // -----------------------------------------------------------------------
1526        // Pre-allocate OIT targets if any transparent items exist.
1527        // Must happen before camera_bg is borrowed (borrow-checker constraint).
1528        // -----------------------------------------------------------------------
1529        {
1530            let needs_oit = if self.use_instancing && !self.instanced_batches.is_empty() {
1531                self.instanced_batches.iter().any(|b| b.is_transparent)
1532            } else {
1533                scene_items
1534                    .iter()
1535                    .any(|i| !i.settings.hidden && i.settings.opacity < 1.0)
1536            } || frame
1537                .scene
1538                .volume_meshes
1539                .iter()
1540                .any(|i| !i.settings.hidden && i.transparency.is_some());
1541            if needs_oit {
1542                let hdr = self.viewport_slots[vp_idx].hdr.as_mut().unwrap();
1543                let [sw, sh] = hdr.scene_size;
1544                self.resources.ensure_viewport_oit(device, hdr, sw, sh);
1545            }
1546        }
1547
1548        // -----------------------------------------------------------------------
1549        // Build the command encoder.
1550        // -----------------------------------------------------------------------
1551        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1552            label: Some("hdr_encoder"),
1553        });
1554
1555        // Per-viewport camera bind group and HDR state for the HDR path.
1556        let slot = &self.viewport_slots[vp_idx];
1557        let camera_bg = &slot.camera_bind_group;
1558        let slot_hdr = slot.hdr.as_ref().expect(
1559            "HDR state missing; ensure_viewport_hdr must be called before render_frame_internal",
1560        );
1561
1562        // -----------------------------------------------------------------------
1563        // HDR scene pass: render geometry into the HDR texture.
1564        // -----------------------------------------------------------------------
1565        {
1566            // Use SSAA target if enabled, otherwise render directly to hdr_texture.
1567            let use_ssaa = ssaa_factor > 1
1568                && slot_hdr.ssaa_colour_view.is_some()
1569                && slot_hdr.ssaa_depth_view.is_some();
1570            let scene_colour_view = if use_ssaa {
1571                slot_hdr.ssaa_colour_view.as_ref().unwrap()
1572            } else {
1573                &slot_hdr.hdr_view
1574            };
1575            let scene_depth_view = if use_ssaa {
1576                slot_hdr.ssaa_depth_view.as_ref().unwrap()
1577            } else {
1578                &slot_hdr.hdr_depth_view
1579            };
1580
1581            let clear_wgpu = wgpu::Color {
1582                r: hdr_clear_rgb[0] as f64,
1583                g: hdr_clear_rgb[1] as f64,
1584                b: hdr_clear_rgb[2] as f64,
1585                // Clear alpha to 0.0 so OIT composite can signal presence via alpha > 0.
1586                // Background pixels remain at alpha=0 and are detected in tone_map.wgsl.
1587                a: 0.0,
1588            };
1589
1590            let hdr_ts_writes =
1591                self.ts_query_set
1592                    .as_ref()
1593                    .map(|qs| wgpu::RenderPassTimestampWrites {
1594                        query_set: qs,
1595                        beginning_of_pass_write_index: Some(0),
1596                        end_of_pass_write_index: Some(1),
1597                    });
1598            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1599                label: Some("hdr_scene_pass"),
1600                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1601                    view: scene_colour_view,
1602                    resolve_target: None,
1603                    ops: wgpu::Operations {
1604                        load: wgpu::LoadOp::Clear(clear_wgpu),
1605                        store: wgpu::StoreOp::Store,
1606                    },
1607                    depth_slice: None,
1608                })],
1609                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1610                    view: scene_depth_view,
1611                    depth_ops: Some(wgpu::Operations {
1612                        load: wgpu::LoadOp::Clear(1.0),
1613                        store: wgpu::StoreOp::Store,
1614                    }),
1615                    stencil_ops: Some(wgpu::Operations {
1616                        load: wgpu::LoadOp::Clear(1),
1617                        store: wgpu::StoreOp::Store,
1618                    }),
1619                }),
1620                timestamp_writes: hdr_ts_writes,
1621                occlusion_query_set: None,
1622            });
1623
1624            let resources = &self.resources;
1625            render_pass.set_bind_group(0, camera_bg, &[]);
1626
1627            // Check skybox eligibility early; drawn after all opaques below.
1628            let show_skybox = frame
1629                .effects
1630                .environment
1631                .as_ref()
1632                .is_some_and(|e| e.show_skybox)
1633                && resources.ibl_skybox_view.is_some();
1634
1635            let use_instancing = self.use_instancing;
1636            let batches = &self.instanced_batches;
1637            let compute_filter_results = &self.compute_filter_results;
1638
1639            if !scene_items.is_empty() {
1640                if use_instancing && !batches.is_empty() {
1641                    let excluded_items: Vec<(usize, &SceneRenderItem)> = scene_items
1642                        .iter()
1643                        .enumerate()
1644                        .filter(|(_, item)| {
1645                            !item.settings.hidden
1646                                && (item.active_attribute.is_some()
1647                                    || item.material.is_two_sided()
1648                                    || item.material.matcap_id().is_some()
1649                                    || item.material.param_vis.is_some()
1650                                    || resources.deform.has_per_instance_deform_data(
1651                                        item.mesh_id,
1652                                        item.deform_instance,
1653                                    ))
1654                                && resources.mesh_store.get(item.mesh_id).is_some()
1655                        })
1656                        .collect();
1657
1658                    // Separate opaque and transparent batches.
1659                    // Carry the global batch index (position in `batches`) alongside each batch
1660                    // so draw_indexed_indirect can compute the correct buffer offset.
1661                    let mut opaque_batches: Vec<(usize, &InstancedBatch)> = Vec::new();
1662                    let mut transparent_batches: Vec<(usize, &InstancedBatch)> = Vec::new();
1663                    for (batch_global_idx, batch) in batches.iter().enumerate() {
1664                        if batch.is_transparent {
1665                            transparent_batches.push((batch_global_idx, batch));
1666                        } else {
1667                            opaque_batches.push((batch_global_idx, batch));
1668                        }
1669                    }
1670
1671                    if !opaque_batches.is_empty() && !frame.viewport.wireframe_mode {
1672                        let use_indirect = self.gpu_culling_enabled
1673                            && resources.hdr_solid_instanced_cull_pipeline.is_some()
1674                            && resources.indirect_args_buf.is_some();
1675
1676                        if use_indirect {
1677                            if let (Some(pipeline), Some(indirect_buf)) = (
1678                                &resources.hdr_solid_instanced_cull_pipeline,
1679                                &resources.indirect_args_buf,
1680                            ) {
1681                                render_pass.set_pipeline(pipeline);
1682                                render_pass.set_bind_group(
1683                                    2,
1684                                    &resources.deform.dummy_bind_group,
1685                                    &[],
1686                                );
1687                                for (batch_global_idx, batch) in &opaque_batches {
1688                                    let Some(mesh) = resources.mesh_store.get(batch.mesh_id) else {
1689                                        continue;
1690                                    };
1691                                    let mat_key = (
1692                                        batch.texture_id.unwrap_or(u64::MAX),
1693                                        batch.normal_map_id.unwrap_or(u64::MAX),
1694                                        batch.ao_map_id.unwrap_or(u64::MAX),
1695                                    );
1696                                    let Some(inst_tex_bg) =
1697                                        resources.instance_cull_bind_groups.get(&mat_key)
1698                                    else {
1699                                        continue;
1700                                    };
1701                                    render_pass.set_bind_group(1, inst_tex_bg, &[]);
1702                                    render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1703                                    render_pass.set_index_buffer(
1704                                        mesh.index_buffer.slice(..),
1705                                        wgpu::IndexFormat::Uint32,
1706                                    );
1707                                    // Each DrawIndexedIndirect entry is 20 bytes; index by global
1708                                    // batch position so the offset matches write_indirect_args output.
1709                                    render_pass.draw_indexed_indirect(
1710                                        indirect_buf,
1711                                        *batch_global_idx as u64 * 20,
1712                                    );
1713                                }
1714                            }
1715                        } else if let Some(ref pipeline) = resources.hdr_solid_instanced_pipeline {
1716                            render_pass.set_pipeline(pipeline);
1717                            render_pass.set_bind_group(2, &resources.deform.dummy_bind_group, &[]);
1718                            for (_, batch) in &opaque_batches {
1719                                let Some(mesh) = resources.mesh_store.get(batch.mesh_id) else {
1720                                    continue;
1721                                };
1722                                let mat_key = (
1723                                    batch.texture_id.unwrap_or(u64::MAX),
1724                                    batch.normal_map_id.unwrap_or(u64::MAX),
1725                                    batch.ao_map_id.unwrap_or(u64::MAX),
1726                                );
1727                                let Some(inst_tex_bg) =
1728                                    resources.instance_bind_groups.get(&mat_key)
1729                                else {
1730                                    continue;
1731                                };
1732                                render_pass.set_bind_group(1, inst_tex_bg, &[]);
1733                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1734                                render_pass.set_index_buffer(
1735                                    mesh.index_buffer.slice(..),
1736                                    wgpu::IndexFormat::Uint32,
1737                                );
1738                                render_pass.draw_indexed(
1739                                    0..mesh.index_count,
1740                                    0,
1741                                    batch.instance_offset
1742                                        ..batch.instance_offset + batch.instance_count,
1743                                );
1744                            }
1745                        }
1746                    }
1747
1748                    // NOTE: transparent_batches are now rendered in the OIT pass below,
1749                    // not in the HDR scene pass. This block intentionally left empty.
1750                    let _ = &transparent_batches; // suppress unused warning
1751
1752                    if frame.viewport.wireframe_mode {
1753                        if let Some(ref hdr_wf) = resources.hdr_wireframe_pipeline {
1754                            let mut wf_idx = 0usize;
1755                            for item in scene_items {
1756                                if item.settings.hidden {
1757                                    continue;
1758                                }
1759                                let Some(mesh) = resources.mesh_store.get(item.mesh_id) else {
1760                                    continue;
1761                                };
1762                                render_pass.set_pipeline(hdr_wf);
1763                                render_pass.set_bind_group(
1764                                    2,
1765                                    resources.deform.instance_bind_group_for(
1766                                        item.mesh_id,
1767                                        item.deform_instance,
1768                                    ),
1769                                    &[],
1770                                );
1771                                let bg = self
1772                                    .wireframe_bind_groups
1773                                    .get(wf_idx)
1774                                    .unwrap_or(&mesh.object_bind_group);
1775                                render_pass.set_bind_group(1, bg, &[]);
1776                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1777                                render_pass.set_index_buffer(
1778                                    mesh.edge_index_buffer.slice(..),
1779                                    wgpu::IndexFormat::Uint32,
1780                                );
1781                                render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
1782                                wf_idx += 1;
1783                            }
1784                        }
1785                    } else if let (Some(hdr_solid), Some(hdr_solid_two_sided)) = (
1786                        &resources.hdr_solid_pipeline,
1787                        &resources.hdr_solid_two_sided_pipeline,
1788                    ) {
1789                        // Only opaque excluded items are drawn in the scene pass; transparent
1790                        // excluded items go to the OIT pass below. LDR draws all excluded
1791                        // items inline (including transparent ones) using the transparent
1792                        // pipeline -- an intentional divergence since HDR uses OIT for
1793                        // transparency throughout.
1794                        for (item_idx, item) in
1795                            excluded_items.iter().copied().filter(|(_, item)| {
1796                                item.settings.opacity >= 1.0 && !item.material.is_blend()
1797                            })
1798                        {
1799                            let Some(mesh) = resources.mesh_store.get(item.mesh_id) else {
1800                                continue;
1801                            };
1802                            let pipeline = if item.material.is_two_sided() {
1803                                hdr_solid_two_sided
1804                            } else {
1805                                hdr_solid
1806                            };
1807                            render_pass.set_pipeline(pipeline);
1808                            render_pass.set_bind_group(
1809                                2,
1810                                resources
1811                                    .deform
1812                                    .instance_bind_group_for(item.mesh_id, item.deform_instance),
1813                                &[],
1814                            );
1815                            let obj_bg = self
1816                                .per_item_object_bind_groups
1817                                .get(item_idx)
1818                                .and_then(|opt| opt.as_ref())
1819                                .unwrap_or(&mesh.object_bind_group);
1820                            render_pass.set_bind_group(1, obj_bg, &[]);
1821                            render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1822                            let filter = compute_filter_results
1823                                .iter()
1824                                .find(|r| r.mesh_id == item.mesh_id);
1825                            if let Some(fr) = filter {
1826                                render_pass.set_index_buffer(
1827                                    fr.index_buffer.slice(..),
1828                                    wgpu::IndexFormat::Uint32,
1829                                );
1830                                render_pass.draw_indexed(0..fr.index_count, 0, 0..1);
1831                            } else {
1832                                render_pass.set_index_buffer(
1833                                    mesh.index_buffer.slice(..),
1834                                    wgpu::IndexFormat::Uint32,
1835                                );
1836                                render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1837                            }
1838                        }
1839                    }
1840
1841                    // Normal-line overlays for instanced items with show_normals set.
1842                    // Instanced batch draws skip per-item logic, so these are drawn
1843                    // here after all batches finish.
1844                    if let Some(hdr_wf) = &resources.hdr_wireframe_pipeline {
1845                        for item in scene_items
1846                            .iter()
1847                            .filter(|i| i.show_normals && !i.settings.hidden)
1848                        {
1849                            let Some(mesh) = resources.mesh_store.get(item.mesh_id) else {
1850                                continue;
1851                            };
1852                            if let Some(ref nl_buf) = mesh.normal_line_buffer {
1853                                if mesh.normal_line_count > 0 {
1854                                    render_pass.set_pipeline(hdr_wf);
1855                                    render_pass.set_bind_group(
1856                                        2,
1857                                        &resources.deform.dummy_bind_group,
1858                                        &[],
1859                                    );
1860                                    render_pass.set_bind_group(1, &mesh.normal_bind_group, &[]);
1861                                    render_pass.set_vertex_buffer(0, nl_buf.slice(..));
1862                                    render_pass.draw(0..mesh.normal_line_count, 0..1);
1863                                }
1864                            }
1865                        }
1866                    }
1867                } else {
1868                    // Per-object path.
1869                    let eye = glam::Vec3::from(frame.camera.render_camera.eye_position);
1870                    let dist_from_eye = |entry: &(usize, &SceneRenderItem)| -> f32 {
1871                        let item = entry.1;
1872                        let pos =
1873                            glam::Vec3::new(item.model[3][0], item.model[3][1], item.model[3][2]);
1874                        (pos - eye).length()
1875                    };
1876
1877                    let mut opaque: Vec<(usize, &SceneRenderItem)> = Vec::new();
1878                    let mut transparent: Vec<(usize, &SceneRenderItem)> = Vec::new();
1879                    for (idx, item) in scene_items.iter().enumerate() {
1880                        if item.settings.hidden || resources.mesh_store.get(item.mesh_id).is_none()
1881                        {
1882                            continue;
1883                        }
1884                        if item.settings.opacity < 1.0 || item.material.is_blend() {
1885                            transparent.push((idx, item));
1886                        } else {
1887                            opaque.push((idx, item));
1888                        }
1889                    }
1890                    opaque.sort_by(|a, b| {
1891                        dist_from_eye(a)
1892                            .partial_cmp(&dist_from_eye(b))
1893                            .unwrap_or(std::cmp::Ordering::Equal)
1894                    });
1895                    transparent.sort_by(|a, b| {
1896                        dist_from_eye(b)
1897                            .partial_cmp(&dist_from_eye(a))
1898                            .unwrap_or(std::cmp::Ordering::Equal)
1899                    });
1900
1901                    let per_item_bgs = &self.per_item_object_bind_groups;
1902                    let draw_item_hdr =
1903                        |render_pass: &mut wgpu::RenderPass<'_>,
1904                         item_idx: usize,
1905                         item: &SceneRenderItem,
1906                         solid_pl: &wgpu::RenderPipeline,
1907                         trans_pl: &wgpu::RenderPipeline,
1908                         wf_pl: &wgpu::RenderPipeline| {
1909                            let mesh = resources.mesh_store.get(item.mesh_id).unwrap();
1910                            let obj_bg = per_item_bgs
1911                                .get(item_idx)
1912                                .and_then(|opt| opt.as_ref())
1913                                .unwrap_or(&mesh.object_bind_group);
1914                            render_pass.set_bind_group(1, obj_bg, &[]);
1915
1916                            let deform_bg = resources
1917                                .deform
1918                                .instance_bind_group_for(item.mesh_id, item.deform_instance);
1919                            let is_face_attr = item.active_attribute.as_ref().map_or(false, |a| {
1920                                matches!(
1921                                    a.kind,
1922                                    crate::resources::AttributeKind::Face
1923                                        | crate::resources::AttributeKind::FaceColour
1924                                        | crate::resources::AttributeKind::Halfedge
1925                                        | crate::resources::AttributeKind::Corner
1926                                )
1927                            });
1928                            if frame.viewport.wireframe_mode {
1929                                render_pass.set_pipeline(wf_pl);
1930                                render_pass.set_bind_group(2, deform_bg, &[]);
1931                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1932                                render_pass.set_index_buffer(
1933                                    mesh.edge_index_buffer.slice(..),
1934                                    wgpu::IndexFormat::Uint32,
1935                                );
1936                                render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
1937                            } else if is_face_attr {
1938                                if let Some(ref fvb) = mesh.face_vertex_buffer {
1939                                    let pl = if item.settings.opacity < 1.0 {
1940                                        trans_pl
1941                                    } else {
1942                                        solid_pl
1943                                    };
1944                                    render_pass.set_pipeline(pl);
1945                                    render_pass.set_bind_group(2, deform_bg, &[]);
1946                                    render_pass.set_vertex_buffer(0, fvb.slice(..));
1947                                    render_pass.draw(0..mesh.index_count, 0..1);
1948                                }
1949                            } else {
1950                                let filter = compute_filter_results
1951                                    .iter()
1952                                    .find(|r| r.mesh_id == item.mesh_id);
1953                                let pl = if item.settings.opacity < 1.0 {
1954                                    trans_pl
1955                                } else {
1956                                    solid_pl
1957                                };
1958                                render_pass.set_pipeline(pl);
1959                                render_pass.set_bind_group(2, deform_bg, &[]);
1960                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1961                                if let Some(fr) = filter {
1962                                    render_pass.set_index_buffer(
1963                                        fr.index_buffer.slice(..),
1964                                        wgpu::IndexFormat::Uint32,
1965                                    );
1966                                    render_pass.draw_indexed(0..fr.index_count, 0, 0..1);
1967                                } else {
1968                                    render_pass.set_index_buffer(
1969                                        mesh.index_buffer.slice(..),
1970                                        wgpu::IndexFormat::Uint32,
1971                                    );
1972                                    render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1973                                }
1974                            }
1975                            if item.show_normals {
1976                                if let Some(ref nl_buf) = mesh.normal_line_buffer {
1977                                    if mesh.normal_line_count > 0 {
1978                                        render_pass.set_pipeline(wf_pl);
1979                                        render_pass.set_bind_group(
1980                                            2,
1981                                            &resources.deform.dummy_bind_group,
1982                                            &[],
1983                                        );
1984                                        render_pass.set_bind_group(1, &mesh.normal_bind_group, &[]);
1985                                        render_pass.set_vertex_buffer(0, nl_buf.slice(..));
1986                                        render_pass.draw(0..mesh.normal_line_count, 0..1);
1987                                    }
1988                                }
1989                            }
1990                        };
1991
1992                    // NOTE: only opaque items are drawn here. Transparent items are
1993                    // routed to the OIT pass below.
1994                    let _ = &transparent; // suppress unused warning
1995                    if let (
1996                        Some(hdr_solid),
1997                        Some(hdr_solid_two_sided),
1998                        Some(hdr_trans),
1999                        Some(hdr_wf),
2000                    ) = (
2001                        &resources.hdr_solid_pipeline,
2002                        &resources.hdr_solid_two_sided_pipeline,
2003                        &resources.hdr_transparent_pipeline,
2004                        &resources.hdr_wireframe_pipeline,
2005                    ) {
2006                        for (item_idx, item) in &opaque {
2007                            let solid_pl = if item.material.is_two_sided() {
2008                                hdr_solid_two_sided
2009                            } else {
2010                                hdr_solid
2011                            };
2012                            draw_item_hdr(
2013                                &mut render_pass,
2014                                *item_idx,
2015                                item,
2016                                solid_pl,
2017                                hdr_trans,
2018                                hdr_wf,
2019                            );
2020                        }
2021                    }
2022                }
2023            }
2024
2025            // Cap fill pass (HDR path : section view cross-section fill).
2026            if !slot.cap_buffers.is_empty() {
2027                if let Some(ref hdr_overlay) = resources.hdr_overlay_pipeline {
2028                    render_pass.set_pipeline(hdr_overlay);
2029                    render_pass.set_bind_group(0, camera_bg, &[]);
2030                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.cap_buffers {
2031                        render_pass.set_bind_group(1, bg, &[]);
2032                        render_pass.set_vertex_buffer(0, vbuf.slice(..));
2033                        render_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
2034                        render_pass.draw_indexed(0..*idx_count, 0, 0..1);
2035                    }
2036                }
2037            }
2038
2039            // Scivis layers (point cloud, glyph, polyline, volume, streamtube,
2040            // image slice, tensor glyph, ribbon, volume surface slice, sprites).
2041            //
2042            // Sprites are routed through a separate post-pass below when SSAA is
2043            // depth. The post-pass targets the ssaa_* attachments and samples
2044            // ssaa_depth_only_view when SSAA is active, the hdr_* attachments
2045            // otherwise. Sprites are always skipped inline here.
2046            let sprite_slice_for_inline: &[crate::resources::SpriteGpuData] = &[];
2047            emit_scivis_draw_calls!(
2048                &self.resources,
2049                &mut render_pass,
2050                &self.point_cloud_gpu_data,
2051                &self.glyph_gpu_data,
2052                &self.polyline_gpu_data,
2053                &self.volume_gpu_data,
2054                &self.streamtube_gpu_data,
2055                camera_bg,
2056                &self.tube_gpu_data,
2057                &self.image_slice_gpu_data,
2058                &self.tensor_glyph_gpu_data,
2059                &self.ribbon_gpu_data,
2060                &self.volume_surface_slice_gpu_data,
2061                sprite_slice_for_inline,
2062                &self.mesh_instance_gpu_data,
2063                true
2064            );
2065
2066            // GPU implicit surface (HDR path, before skybox).
2067            if !self.implicit_gpu_data.is_empty() {
2068                if let Some(ref dual) = self.resources.implicit_pipeline {
2069                    render_pass.set_pipeline(dual.for_format(true));
2070                    render_pass.set_bind_group(0, camera_bg, &[]);
2071                    for gpu in &self.implicit_gpu_data {
2072                        render_pass.set_bind_group(1, &gpu.bind_group, &[]);
2073                        render_pass.draw(0..6, 0..1);
2074                    }
2075                }
2076            }
2077            // GPU marching cubes indirect draw (HDR path).
2078            if !self.mc_gpu_data.is_empty() {
2079                render_pass.set_bind_group(0, camera_bg, &[]);
2080                for mc in &self.mc_gpu_data {
2081                    let vol = &self.resources.mc_volumes[mc.volume_idx];
2082                    if mc.wireframe || frame.viewport.wireframe_mode {
2083                        if let Some(ref dual) = self.resources.mc_wireframe_pipeline {
2084                            render_pass.set_pipeline(dual.for_format(true));
2085                            for (slab, wire_bg) in vol.slabs.iter().zip(mc.wire_slab_bgs.iter()) {
2086                                render_pass.set_bind_group(1, wire_bg, &[]);
2087                                render_pass.draw_indirect(&slab.wire_indirect_buf, 0);
2088                            }
2089                        }
2090                    } else if let Some(ref dual) = self.resources.mc_surface_pipeline {
2091                        render_pass.set_pipeline(dual.for_format(true));
2092                        render_pass.set_bind_group(1, &mc.render_bg, &[]);
2093                        for slab in &vol.slabs {
2094                            render_pass.set_vertex_buffer(0, slab.vertex_buf.slice(..));
2095                            render_pass.draw_indirect(&slab.indirect_buf, 0);
2096                        }
2097                    }
2098                }
2099            }
2100
2101            // Gaussian splats (HDR path).
2102            if !self.gaussian_splat_draw_data.is_empty() {
2103                if let Some(ref dual) = self.resources.gaussian_splat_pipeline {
2104                    render_pass.set_pipeline(dual.for_format(true));
2105                    render_pass.set_bind_group(0, camera_bg, &[]);
2106                    for dd in &self.gaussian_splat_draw_data {
2107                        if dd.wireframe {
2108                            continue;
2109                        }
2110                        if let Some(set) = self.resources.gaussian_splat_store.get(dd.store_index) {
2111                            if let Some(Some(vp_sort)) = set.viewport_sort.get(dd.viewport_index) {
2112                                render_pass.set_bind_group(1, &vp_sort.render_bg, &[]);
2113                                render_pass.draw(0..6, 0..dd.count);
2114                            }
2115                        }
2116                    }
2117                }
2118            }
2119            // TransparentVolumeMesh boundary wireframe overlay (HDR path).
2120            if !self.tvm_wireframe_draws.is_empty() {
2121                if let (Some(tvm_bg), Some(hdr_wf)) =
2122                    (&self.tvm_wireframe_bg, &resources.hdr_wireframe_pipeline)
2123                {
2124                    for mesh_id in &self.tvm_wireframe_draws {
2125                        if let Some(mesh) = resources.mesh_store.get(*mesh_id) {
2126                            render_pass.set_pipeline(hdr_wf);
2127                            render_pass.set_bind_group(1, tvm_bg, &[]);
2128                            render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
2129                            render_pass.set_index_buffer(
2130                                mesh.edge_index_buffer.slice(..),
2131                                wgpu::IndexFormat::Uint32,
2132                            );
2133                            render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
2134                        }
2135                    }
2136                }
2137            }
2138
2139            // Item-type plugin paint: after built-in opaques, before
2140            // skybox. Standard group-0 bindings are already bound.
2141            self.dispatch_plugin_paint(&mut render_pass, frame);
2142
2143            // Draw skybox last among opaques : only uncovered sky pixels pass depth == 1.0.
2144            if show_skybox {
2145                render_pass.set_bind_group(0, camera_bg, &[]);
2146                render_pass.set_pipeline(&resources.skybox_pipeline);
2147                render_pass.draw(0..3, 0..1);
2148            }
2149        }
2150
2151        // -----------------------------------------------------------------------
2152        // Sprite post-pass: redraws sprite batches in two passes so that the
2153        // soft-particle shader path can sample resolved scene depth.
2154        //
2155        // The depth-write batch runs first with the depth attachment writable
2156        // and the fallback group-2 bind group, since the live depth view is
2157        // also the attachment and cannot be sampled at the same time.
2158        //
2159        // The transparent batch runs after with the depth attachment in
2160        // read-only mode and the per-viewport bind group, which lets the
2161        // sprite shader sample the live scene depth and apply the soft fade.
2162        //
2163        // Selects the ssaa_* colour/depth/sample views when SSAA is active so
2164        // sprites draw at supersampled resolution and resolve with the rest of
2165        // the scene; otherwise targets the hdr_* views directly.
2166        // -----------------------------------------------------------------------
2167        if !self.sprite_gpu_data.is_empty() {
2168            let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
2169            let camera_bg = &self.viewport_slots[vp_idx].camera_bind_group;
2170            let resources = &self.resources;
2171
2172            let use_ssaa = ssaa_factor > 1
2173                && slot_hdr.ssaa_colour_view.is_some()
2174                && slot_hdr.ssaa_depth_view.is_some()
2175                && slot_hdr.ssaa_depth_only_view.is_some();
2176            let colour_view = if use_ssaa {
2177                slot_hdr.ssaa_colour_view.as_ref().unwrap()
2178            } else {
2179                &slot_hdr.hdr_view
2180            };
2181            let depth_view = if use_ssaa {
2182                slot_hdr.ssaa_depth_view.as_ref().unwrap()
2183            } else {
2184                &slot_hdr.hdr_depth_view
2185            };
2186            let depth_only_view = if use_ssaa {
2187                slot_hdr.ssaa_depth_only_view.as_ref().unwrap()
2188            } else {
2189                &slot_hdr.hdr_depth_only_view
2190            };
2191
2192            let any_depth_write = self.sprite_gpu_data.iter().any(|s| s.depth_write);
2193            let any_transparent = self.sprite_gpu_data.iter().any(|s| !s.depth_write);
2194
2195            let buckets = [
2196                // (depth_write, blend, lit, pipeline)
2197                (
2198                    true,
2199                    crate::renderer::SpriteBlend::AlphaBlend,
2200                    false,
2201                    resources.sprite_pipeline_depth_write.as_ref(),
2202                ),
2203                (
2204                    true,
2205                    crate::renderer::SpriteBlend::Additive,
2206                    false,
2207                    resources.sprite_pipeline_additive_depth_write.as_ref(),
2208                ),
2209                (
2210                    true,
2211                    crate::renderer::SpriteBlend::Premultiplied,
2212                    false,
2213                    resources.sprite_pipeline_premultiplied_depth_write.as_ref(),
2214                ),
2215                (
2216                    false,
2217                    crate::renderer::SpriteBlend::AlphaBlend,
2218                    false,
2219                    resources.sprite_pipeline.as_ref(),
2220                ),
2221                (
2222                    false,
2223                    crate::renderer::SpriteBlend::Additive,
2224                    false,
2225                    resources.sprite_pipeline_additive.as_ref(),
2226                ),
2227                (
2228                    false,
2229                    crate::renderer::SpriteBlend::Premultiplied,
2230                    false,
2231                    resources.sprite_pipeline_premultiplied.as_ref(),
2232                ),
2233                (
2234                    true,
2235                    crate::renderer::SpriteBlend::AlphaBlend,
2236                    true,
2237                    resources.sprite_lit_pipeline_depth_write.as_ref(),
2238                ),
2239                (
2240                    true,
2241                    crate::renderer::SpriteBlend::Additive,
2242                    true,
2243                    resources.sprite_lit_pipeline_additive_depth_write.as_ref(),
2244                ),
2245                (
2246                    true,
2247                    crate::renderer::SpriteBlend::Premultiplied,
2248                    true,
2249                    resources
2250                        .sprite_lit_pipeline_premultiplied_depth_write
2251                        .as_ref(),
2252                ),
2253                (
2254                    false,
2255                    crate::renderer::SpriteBlend::AlphaBlend,
2256                    true,
2257                    resources.sprite_lit_pipeline.as_ref(),
2258                ),
2259                (
2260                    false,
2261                    crate::renderer::SpriteBlend::Additive,
2262                    true,
2263                    resources.sprite_lit_pipeline_additive.as_ref(),
2264                ),
2265                (
2266                    false,
2267                    crate::renderer::SpriteBlend::Premultiplied,
2268                    true,
2269                    resources.sprite_lit_pipeline_premultiplied.as_ref(),
2270                ),
2271            ];
2272            let lit_fallback_bg = resources.sprite_lit_fallback_bg.as_ref();
2273
2274            let fallback_soft_bg = resources.sprite_soft_fallback_bg.as_ref();
2275
2276            // Pass 1: depth-write sprites, depth attachment writable, fallback
2277            // bound at group 2 (the live depth view is aliased to the
2278            // attachment in this pass and cannot also be sampled).
2279            if any_depth_write {
2280                if let Some(fallback_soft_bg) = fallback_soft_bg {
2281                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2282                        label: Some("sprite_depth_write_pass"),
2283                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2284                            view: colour_view,
2285                            resolve_target: None,
2286                            ops: wgpu::Operations {
2287                                load: wgpu::LoadOp::Load,
2288                                store: wgpu::StoreOp::Store,
2289                            },
2290                            depth_slice: None,
2291                        })],
2292                        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2293                            view: depth_view,
2294                            depth_ops: Some(wgpu::Operations {
2295                                load: wgpu::LoadOp::Load,
2296                                store: wgpu::StoreOp::Store,
2297                            }),
2298                            stencil_ops: Some(wgpu::Operations {
2299                                load: wgpu::LoadOp::Load,
2300                                store: wgpu::StoreOp::Store,
2301                            }),
2302                        }),
2303                        timestamp_writes: None,
2304                        occlusion_query_set: None,
2305                    });
2306                    for (depth_write, blend, lit, pipeline) in &buckets {
2307                        if !*depth_write {
2308                            continue;
2309                        }
2310                        let Some(dual) = pipeline else { continue };
2311                        let mut bound = false;
2312                        for sprite in self.sprite_gpu_data.iter() {
2313                            if sprite.wireframe
2314                                || !sprite.depth_write
2315                                || sprite.blend != *blend
2316                                || sprite.lit != *lit
2317                                || sprite.refraction_strength > 0.0
2318                            {
2319                                continue;
2320                            }
2321                            if !bound {
2322                                pass.set_pipeline(dual.for_format(true));
2323                                pass.set_bind_group(0, camera_bg, &[]);
2324                                pass.set_bind_group(2, fallback_soft_bg, &[]);
2325                                bound = true;
2326                            }
2327                            pass.set_bind_group(1, &sprite.bind_group, &[]);
2328                            if *lit {
2329                                let normal_bg = sprite.lit_normal_bg.as_ref().or(lit_fallback_bg);
2330                                if let Some(bg) = normal_bg {
2331                                    pass.set_bind_group(3, bg, &[]);
2332                                }
2333                            }
2334                            pass.set_vertex_buffer(0, sprite.vertex_buffer.slice(..));
2335                            pass.draw(0..6, 0..sprite.sprite_count);
2336                        }
2337                    }
2338                }
2339            }
2340
2341            // Pass 2: transparent sprites, depth attachment read-only so the
2342            // live depth view can be sampled by the sprite shader for fade.
2343            if any_transparent {
2344                let real_soft_bg = if let (Some(bgl), Some(sampler)) = (
2345                    resources.sprite_soft_bgl.as_ref(),
2346                    resources.sprite_soft_sampler.as_ref(),
2347                ) {
2348                    Some(device.create_bind_group(&wgpu::BindGroupDescriptor {
2349                        label: Some("sprite_soft_bg"),
2350                        layout: bgl,
2351                        entries: &[
2352                            wgpu::BindGroupEntry {
2353                                binding: 0,
2354                                resource: wgpu::BindingResource::TextureView(depth_only_view),
2355                            },
2356                            wgpu::BindGroupEntry {
2357                                binding: 1,
2358                                resource: wgpu::BindingResource::Sampler(sampler),
2359                            },
2360                        ],
2361                    }))
2362                } else {
2363                    None
2364                };
2365
2366                if let Some(real_soft_bg) = real_soft_bg.as_ref() {
2367                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2368                        label: Some("sprite_transparent_pass"),
2369                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2370                            view: colour_view,
2371                            resolve_target: None,
2372                            ops: wgpu::Operations {
2373                                load: wgpu::LoadOp::Load,
2374                                store: wgpu::StoreOp::Store,
2375                            },
2376                            depth_slice: None,
2377                        })],
2378                        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2379                            view: depth_view,
2380                            depth_ops: None,
2381                            stencil_ops: None,
2382                        }),
2383                        timestamp_writes: None,
2384                        occlusion_query_set: None,
2385                    });
2386                    for (depth_write, blend, lit, pipeline) in &buckets {
2387                        if *depth_write {
2388                            continue;
2389                        }
2390                        let Some(dual) = pipeline else { continue };
2391                        let mut bound = false;
2392                        for sprite in self.sprite_gpu_data.iter() {
2393                            if sprite.wireframe
2394                                || sprite.depth_write
2395                                || sprite.blend != *blend
2396                                || sprite.lit != *lit
2397                                || sprite.refraction_strength > 0.0
2398                            {
2399                                continue;
2400                            }
2401                            if !bound {
2402                                pass.set_pipeline(dual.for_format(true));
2403                                pass.set_bind_group(0, camera_bg, &[]);
2404                                pass.set_bind_group(2, real_soft_bg, &[]);
2405                                bound = true;
2406                            }
2407                            pass.set_bind_group(1, &sprite.bind_group, &[]);
2408                            if *lit {
2409                                let normal_bg = sprite.lit_normal_bg.as_ref().or(lit_fallback_bg);
2410                                if let Some(bg) = normal_bg {
2411                                    pass.set_bind_group(3, bg, &[]);
2412                                }
2413                            }
2414                            pass.set_vertex_buffer(0, sprite.vertex_buffer.slice(..));
2415                            pass.draw(0..6, 0..sprite.sprite_count);
2416                        }
2417                    }
2418                }
2419            }
2420        }
2421
2422        // -----------------------------------------------------------------------
2423        // GPU particle sprite pass: each particle system draws its full
2424        // capacity as billboards through a sprite-shader variant that reads
2425        // positions and per-instance data from the system's GPU buffer. Dead
2426        // particles emit a degenerate vertex and contribute no fragments.
2427        // -----------------------------------------------------------------------
2428        if !self.particle_gpu_data.is_empty() {
2429            let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
2430            let camera_bg = &self.viewport_slots[vp_idx].camera_bind_group;
2431            let resources = &self.resources;
2432
2433            let use_ssaa = ssaa_factor > 1
2434                && slot_hdr.ssaa_colour_view.is_some()
2435                && slot_hdr.ssaa_depth_view.is_some();
2436            let colour_view = if use_ssaa {
2437                slot_hdr.ssaa_colour_view.as_ref().unwrap()
2438            } else {
2439                &slot_hdr.hdr_view
2440            };
2441            let depth_view = if use_ssaa {
2442                slot_hdr.ssaa_depth_view.as_ref().unwrap()
2443            } else {
2444                &slot_hdr.hdr_depth_view
2445            };
2446
2447            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2448                label: Some("gpu_particle_sprite_pass"),
2449                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2450                    view: colour_view,
2451                    resolve_target: None,
2452                    ops: wgpu::Operations {
2453                        load: wgpu::LoadOp::Load,
2454                        store: wgpu::StoreOp::Store,
2455                    },
2456                    depth_slice: None,
2457                })],
2458                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2459                    view: depth_view,
2460                    depth_ops: Some(wgpu::Operations {
2461                        load: wgpu::LoadOp::Load,
2462                        store: wgpu::StoreOp::Store,
2463                    }),
2464                    stencil_ops: None,
2465                }),
2466                timestamp_writes: None,
2467                occlusion_query_set: None,
2468            });
2469            pass.set_bind_group(0, camera_bg, &[]);
2470            let particle_lit_fallback = resources.particle_sprite_lit_fallback_bg.as_ref();
2471            for pd in &self.particle_gpu_data {
2472                if pd.hidden {
2473                    continue;
2474                }
2475                let Some(system) = resources
2476                    .particle_systems
2477                    .get(pd.system_idx)
2478                    .and_then(|s| s.as_ref())
2479                    .filter(|s| s.alive)
2480                else {
2481                    continue;
2482                };
2483                match pd.route {
2484                    crate::resources::gpu_particles::ParticleDrawRoute::Sprite { lit } => {
2485                        let dual = match (pd.blend, lit) {
2486                            (crate::renderer::SpriteBlend::Additive, false) => {
2487                                resources.particle_sprite_pipeline_additive.as_ref()
2488                            }
2489                            (crate::renderer::SpriteBlend::Premultiplied, false) => {
2490                                resources.particle_sprite_pipeline_premultiplied.as_ref()
2491                            }
2492                            (crate::renderer::SpriteBlend::AlphaBlend, false) => {
2493                                resources.particle_sprite_pipeline_alpha.as_ref()
2494                            }
2495                            (crate::renderer::SpriteBlend::Additive, true) => {
2496                                resources.particle_sprite_lit_pipeline_additive.as_ref()
2497                            }
2498                            (crate::renderer::SpriteBlend::Premultiplied, true) => resources
2499                                .particle_sprite_lit_pipeline_premultiplied
2500                                .as_ref(),
2501                            (crate::renderer::SpriteBlend::AlphaBlend, true) => {
2502                                resources.particle_sprite_lit_pipeline_alpha.as_ref()
2503                            }
2504                        };
2505                        let Some(dual) = dual else { continue };
2506                        let Some(draw_bg) = system.draw_bg.as_ref() else {
2507                            continue;
2508                        };
2509                        pass.set_pipeline(dual.for_format(true));
2510                        pass.set_bind_group(1, draw_bg, &[]);
2511                        if lit {
2512                            let normal_bg =
2513                                system.draw_lit_normal_bg.as_ref().or(particle_lit_fallback);
2514                            if let Some(bg) = normal_bg {
2515                                pass.set_bind_group(2, bg, &[]);
2516                            }
2517                        }
2518                        pass.draw(0..6, 0..system.capacity);
2519                    }
2520                    crate::resources::gpu_particles::ParticleDrawRoute::Mesh { mesh_id } => {
2521                        let dual = match pd.blend {
2522                            crate::renderer::SpriteBlend::Additive => {
2523                                resources.particle_mesh_pipeline_additive.as_ref()
2524                            }
2525                            crate::renderer::SpriteBlend::Premultiplied => {
2526                                resources.particle_mesh_pipeline_premultiplied.as_ref()
2527                            }
2528                            crate::renderer::SpriteBlend::AlphaBlend => {
2529                                resources.particle_mesh_pipeline_alpha.as_ref()
2530                            }
2531                        };
2532                        let Some(dual) = dual else { continue };
2533                        let Some(draw_bg) = system.draw_bg_mesh.as_ref() else {
2534                            continue;
2535                        };
2536                        let Some(mesh) = resources.mesh_store.get(
2537                            crate::resources::mesh_store::MeshId::from_index(mesh_id as usize),
2538                        ) else {
2539                            continue;
2540                        };
2541                        pass.set_pipeline(dual.for_format(true));
2542                        pass.set_bind_group(1, draw_bg, &[]);
2543                        pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
2544                        pass.set_index_buffer(
2545                            mesh.index_buffer.slice(..),
2546                            wgpu::IndexFormat::Uint32,
2547                        );
2548                        pass.draw_indexed(0..mesh.index_count, 0, 0..system.capacity);
2549                    }
2550                }
2551            }
2552        }
2553
2554        // -----------------------------------------------------------------------
2555        // Refractive sprite pass.
2556        //
2557        // Sprites flagged with `refraction_strength` skip the normal sprite
2558        // pass and draw here instead. The renderer copies the HDR colour into
2559        // a separate resolve texture, then each refractive sprite samples
2560        // that texture at an offset driven by its own texture (R/G channels
2561        // as signed displacement, alpha as mask).
2562        //
2563        // Non-SSAA HDR path only: SSAA would need a resolve at supersampled
2564        // resolution and the soft-particle post-pass machinery does not yet
2565        // share its resolve. This matches the soft-particle constraint.
2566        // -----------------------------------------------------------------------
2567        let has_refractive = self
2568            .sprite_gpu_data
2569            .iter()
2570            .any(|s| s.refraction_strength > 0.0 && !s.wireframe);
2571        if has_refractive && ssaa_factor == 1 {
2572            let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
2573            let camera_bg = &self.viewport_slots[vp_idx].camera_bind_group;
2574            let resources = &self.resources;
2575            let physical_w = slot_hdr.hdr_texture.size().width;
2576            let physical_h = slot_hdr.hdr_texture.size().height;
2577
2578            // Allocate or resize the resolve texture so it matches the HDR
2579            // attachment exactly. The copy depends on identical dimensions
2580            // and format (Rgba16Float). Lives in side-storage indexed by
2581            // viewport so the outer borrows on `viewport_slots` stay
2582            // immutable.
2583            while self.sprite_refraction_resolves.len() <= vp_idx {
2584                self.sprite_refraction_resolves.push(None);
2585            }
2586            let need_realloc = match self.sprite_refraction_resolves[vp_idx].as_ref() {
2587                Some(r) => r.size != [physical_w, physical_h],
2588                None => true,
2589            };
2590            if need_realloc {
2591                let tex = device.create_texture(&wgpu::TextureDescriptor {
2592                    label: Some("sprite_refraction_resolve"),
2593                    size: wgpu::Extent3d {
2594                        width: physical_w.max(1),
2595                        height: physical_h.max(1),
2596                        depth_or_array_layers: 1,
2597                    },
2598                    mip_level_count: 1,
2599                    sample_count: 1,
2600                    dimension: wgpu::TextureDimension::D2,
2601                    format: wgpu::TextureFormat::Rgba16Float,
2602                    usage: wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::TEXTURE_BINDING,
2603                    view_formats: &[],
2604                });
2605                let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
2606                self.sprite_refraction_resolves[vp_idx] = Some(SpriteRefractionResolve {
2607                    texture: tex,
2608                    view,
2609                    size: [physical_w, physical_h],
2610                });
2611            }
2612            let resolve = self.sprite_refraction_resolves[vp_idx].as_ref().unwrap();
2613            let resolve_tex = &resolve.texture;
2614            let resolve_view = &resolve.view;
2615
2616            // Copy current HDR colour -> resolve texture so the refraction
2617            // shader can sample the scene without reading from its render
2618            // attachment.
2619            encoder.copy_texture_to_texture(
2620                wgpu::TexelCopyTextureInfo {
2621                    texture: &slot_hdr.hdr_texture,
2622                    mip_level: 0,
2623                    origin: wgpu::Origin3d::ZERO,
2624                    aspect: wgpu::TextureAspect::All,
2625                },
2626                wgpu::TexelCopyTextureInfo {
2627                    texture: resolve_tex,
2628                    mip_level: 0,
2629                    origin: wgpu::Origin3d::ZERO,
2630                    aspect: wgpu::TextureAspect::All,
2631                },
2632                wgpu::Extent3d {
2633                    width: physical_w.max(1),
2634                    height: physical_h.max(1),
2635                    depth_or_array_layers: 1,
2636                },
2637            );
2638
2639            // Build the group-2 bind group: sampled resolve view + sampler.
2640            let refraction_bgl = resources.sprite_refraction_bgl.as_ref().unwrap();
2641            let refraction_sampler = resources.sprite_refraction_sampler.as_ref().unwrap();
2642            let refraction_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
2643                label: Some("sprite_refraction_bg"),
2644                layout: refraction_bgl,
2645                entries: &[
2646                    wgpu::BindGroupEntry {
2647                        binding: 0,
2648                        resource: wgpu::BindingResource::TextureView(resolve_view),
2649                    },
2650                    wgpu::BindGroupEntry {
2651                        binding: 1,
2652                        resource: wgpu::BindingResource::Sampler(refraction_sampler),
2653                    },
2654                ],
2655            });
2656
2657            if let Some(pipeline) = resources.sprite_refraction_pipeline.as_ref() {
2658                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2659                    label: Some("sprite_refraction_pass"),
2660                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2661                        view: &slot_hdr.hdr_view,
2662                        resolve_target: None,
2663                        ops: wgpu::Operations {
2664                            load: wgpu::LoadOp::Load,
2665                            store: wgpu::StoreOp::Store,
2666                        },
2667                        depth_slice: None,
2668                    })],
2669                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2670                        view: &slot_hdr.hdr_depth_view,
2671                        depth_ops: Some(wgpu::Operations {
2672                            load: wgpu::LoadOp::Load,
2673                            store: wgpu::StoreOp::Store,
2674                        }),
2675                        stencil_ops: None,
2676                    }),
2677                    timestamp_writes: None,
2678                    occlusion_query_set: None,
2679                });
2680                pass.set_pipeline(pipeline);
2681                pass.set_bind_group(0, camera_bg, &[]);
2682                pass.set_bind_group(2, &refraction_bg, &[]);
2683                for sprite in self.sprite_gpu_data.iter() {
2684                    if sprite.refraction_strength <= 0.0 || sprite.wireframe {
2685                        continue;
2686                    }
2687                    pass.set_bind_group(1, &sprite.bind_group, &[]);
2688                    pass.set_vertex_buffer(0, sprite.vertex_buffer.slice(..));
2689                    pass.draw(0..6, 0..sprite.sprite_count);
2690                }
2691            }
2692        }
2693
2694        // -----------------------------------------------------------------------
2695        // SSAA resolve pass: downsample supersampled scene -> hdr_texture.
2696        // Only runs when ssaa_factor > 1 and the resolve pipeline is available.
2697        // -----------------------------------------------------------------------
2698        if ssaa_factor > 1 {
2699            let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
2700            if let (Some(pipeline), Some(bg)) = (
2701                &self.resources.ssaa_resolve_pipeline,
2702                &slot_hdr.ssaa_resolve_bind_group,
2703            ) {
2704                let mut resolve_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2705                    label: Some("ssaa_resolve_pass"),
2706                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2707                        view: &slot_hdr.hdr_view,
2708                        resolve_target: None,
2709                        ops: wgpu::Operations {
2710                            load: wgpu::LoadOp::Load,
2711                            store: wgpu::StoreOp::Store,
2712                        },
2713                        depth_slice: None,
2714                    })],
2715                    depth_stencil_attachment: None,
2716                    timestamp_writes: None,
2717                    occlusion_query_set: None,
2718                });
2719                resolve_pass.set_pipeline(pipeline);
2720                resolve_pass.set_bind_group(0, bg, &[]);
2721                resolve_pass.draw(0..3, 0..1);
2722            }
2723        }
2724
2725        // -----------------------------------------------------------------------
2726        // Decal exclude pass (D5): stamp stencil = 0 on non-receiver surfaces.
2727        // Runs after the opaque pass, before the decal pass.
2728        // -----------------------------------------------------------------------
2729        if !self.decal_exclude_items.is_empty() {
2730            if let Some(exclude_pl) = self.resources.decal_exclude_pipeline.as_ref() {
2731                let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
2732                let camera_bg = &self.viewport_slots[vp_idx].camera_bind_group;
2733                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2734                    label: Some("decal_exclude_pass"),
2735                    color_attachments: &[],
2736                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2737                        view: &slot_hdr.hdr_depth_view,
2738                        depth_ops: Some(wgpu::Operations {
2739                            load: wgpu::LoadOp::Load,
2740                            store: wgpu::StoreOp::Store,
2741                        }),
2742                        stencil_ops: Some(wgpu::Operations {
2743                            load: wgpu::LoadOp::Load,
2744                            store: wgpu::StoreOp::Store,
2745                        }),
2746                    }),
2747                    timestamp_writes: None,
2748                    occlusion_query_set: None,
2749                });
2750                pass.set_pipeline(exclude_pl);
2751                pass.set_stencil_reference(0);
2752                pass.set_bind_group(0, camera_bg, &[]);
2753                for item in &self.decal_exclude_items {
2754                    if let Some(mesh) = self.resources.mesh_store.get(item.mesh_id) {
2755                        pass.set_bind_group(1, &item.bind_group, &[]);
2756                        pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
2757                        if mesh.index_count > 0 {
2758                            pass.set_index_buffer(
2759                                mesh.index_buffer.slice(..),
2760                                wgpu::IndexFormat::Uint32,
2761                            );
2762                            pass.draw_indexed(0..mesh.index_count, 0, 0..1);
2763                        }
2764                    }
2765                }
2766            }
2767        }
2768
2769        // -----------------------------------------------------------------------
2770        // Decal pass (D1): projects each decal texture onto opaque surfaces.
2771        // Reads scene depth as a texture; no depth attachment.
2772        // Runs after opaque geometry and SSAA resolve, before transparent passes.
2773        // -----------------------------------------------------------------------
2774        if !self.decal_gpu_data.is_empty() {
2775            let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
2776            let camera_bg = &self.viewport_slots[vp_idx].camera_bind_group;
2777            let depth_bg = &slot_hdr.decal_depth_bg;
2778            let replace_pipeline = self.resources.decal_replace_pipeline.as_ref();
2779            let multiply_pipeline = self.resources.decal_multiply_pipeline.as_ref();
2780            let additive_pipeline = self.resources.decal_additive_pipeline.as_ref();
2781            if replace_pipeline.is_some()
2782                || multiply_pipeline.is_some()
2783                || additive_pipeline.is_some()
2784            {
2785                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2786                    label: Some("decal_pass"),
2787                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2788                        view: &slot_hdr.hdr_view,
2789                        resolve_target: None,
2790                        ops: wgpu::Operations {
2791                            load: wgpu::LoadOp::Load,
2792                            store: wgpu::StoreOp::Store,
2793                        },
2794                        depth_slice: None,
2795                    })],
2796                    depth_stencil_attachment: None,
2797                    timestamp_writes: None,
2798                    occlusion_query_set: None,
2799                });
2800                pass.set_bind_group(0, camera_bg, &[]);
2801                pass.set_bind_group(1, depth_bg, &[]);
2802                for gpu in &self.decal_gpu_data {
2803                    let pipeline = match gpu.blend_mode {
2804                        crate::renderer::DecalBlendMode::Replace => replace_pipeline,
2805                        crate::renderer::DecalBlendMode::Multiply => multiply_pipeline,
2806                        crate::renderer::DecalBlendMode::Additive => additive_pipeline,
2807                    };
2808                    if let Some(pl) = pipeline {
2809                        pass.set_pipeline(&pl.hdr);
2810                        pass.set_bind_group(2, &gpu.bind_group, &[]);
2811                        pass.draw(0..6, 0..1);
2812                    }
2813                }
2814            }
2815        }
2816
2817        // -----------------------------------------------------------------------
2818        // Sub-object highlight pass: face fill, edge lines, vertex sprites.
2819        // Runs after opaque geometry (depth buffer is ready) and before OIT so
2820        // highlights are not occluded by opaque surfaces.
2821        // -----------------------------------------------------------------------
2822        if let Some(sub_hl) = self.viewport_slots[vp_idx].sub_highlight.as_ref() {
2823            let resources = &self.resources;
2824            if let (Some(fill_pl), Some(edge_pl), Some(sprite_pl)) = (
2825                &resources.sub_highlight_fill_pipeline,
2826                &resources.sub_highlight_edge_pipeline,
2827                &resources.sub_highlight_sprite_pipeline,
2828            ) {
2829                let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
2830                let camera_bg = &self.viewport_slots[vp_idx].camera_bind_group;
2831                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2832                    label: Some("sub_highlight_pass"),
2833                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2834                        view: &slot_hdr.hdr_view,
2835                        resolve_target: None,
2836                        ops: wgpu::Operations {
2837                            load: wgpu::LoadOp::Load,
2838                            store: wgpu::StoreOp::Store,
2839                        },
2840                        depth_slice: None,
2841                    })],
2842                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2843                        view: &slot_hdr.hdr_depth_view,
2844                        depth_ops: Some(wgpu::Operations {
2845                            load: wgpu::LoadOp::Load,
2846                            // Store even though depth_write_enabled=false on all
2847                            // sub-highlight pipelines: the values are unchanged, but
2848                            // StoreOp::Discard would invalidate the tile on Metal and
2849                            // cause subsequent passes (tone_map, grid, screen images)
2850                            // to read 0.0, making the background go black.
2851                            store: wgpu::StoreOp::Store,
2852                        }),
2853                        stencil_ops: None,
2854                    }),
2855                    timestamp_writes: None,
2856                    occlusion_query_set: None,
2857                });
2858
2859                if sub_hl.fill_vertex_count > 0 {
2860                    pass.set_pipeline(fill_pl);
2861                    pass.set_bind_group(0, camera_bg, &[]);
2862                    pass.set_bind_group(1, &sub_hl.fill_bind_group, &[]);
2863                    pass.set_vertex_buffer(0, sub_hl.fill_vertex_buf.slice(..));
2864                    pass.draw(0..sub_hl.fill_vertex_count, 0..1);
2865                }
2866                if sub_hl.edge_segment_count > 0 {
2867                    pass.set_pipeline(edge_pl);
2868                    pass.set_bind_group(0, camera_bg, &[]);
2869                    pass.set_bind_group(1, &sub_hl.edge_bind_group, &[]);
2870                    pass.set_vertex_buffer(0, sub_hl.edge_vertex_buf.slice(..));
2871                    pass.draw(0..6, 0..sub_hl.edge_segment_count);
2872                }
2873                if sub_hl.sprite_point_count > 0 {
2874                    pass.set_pipeline(sprite_pl);
2875                    pass.set_bind_group(0, camera_bg, &[]);
2876                    pass.set_bind_group(1, &sub_hl.sprite_bind_group, &[]);
2877                    pass.set_vertex_buffer(0, sub_hl.sprite_vertex_buf.slice(..));
2878                    pass.draw(0..6, 0..sub_hl.sprite_point_count);
2879                }
2880            }
2881        }
2882
2883        // -----------------------------------------------------------------------
2884        // OIT pass: render transparent items into accum + reveal textures.
2885        // Completely skipped when no transparent items exist (zero overhead).
2886        // -----------------------------------------------------------------------
2887        let has_transparent = if self.use_instancing && !self.instanced_batches.is_empty() {
2888            // Transparent instanced batches go through OIT. Transparent excluded items
2889            // (two-sided, active-attribute, matcap) are not in any instanced batch, so
2890            // they must also be checked here -- otherwise the OIT pass is skipped and
2891            // those items are invisible.
2892            self.instanced_batches.iter().any(|b| b.is_transparent)
2893                || scene_items.iter().any(|i| {
2894                    !i.settings.hidden
2895                        && (i.settings.opacity < 1.0 || i.material.is_blend())
2896                        && (i.active_attribute.is_some()
2897                            || i.material.is_two_sided()
2898                            || i.material.matcap_id().is_some()
2899                            || i.material.param_vis.is_some()
2900                            || self
2901                                .resources
2902                                .deform
2903                                .has_per_instance_deform_data(i.mesh_id, i.deform_instance))
2904                })
2905        } else {
2906            scene_items
2907                .iter()
2908                .any(|i| !i.settings.hidden && (i.settings.opacity < 1.0 || i.material.is_blend()))
2909        } || frame
2910            .scene
2911            .volume_meshes
2912            .iter()
2913            .any(|i| !i.settings.hidden && i.transparency.is_some());
2914
2915        if has_transparent {
2916            // OIT targets already allocated in the pre-pass above.
2917            if let (Some(accum_view), Some(reveal_view)) = (
2918                slot_hdr.oit_accum_view.as_ref(),
2919                slot_hdr.oit_reveal_view.as_ref(),
2920            ) {
2921                let hdr_depth_view = &slot_hdr.hdr_depth_view;
2922                // Clear accum to (0,0,0,0), reveal to 1.0 (no contribution yet).
2923                let mut oit_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2924                    label: Some("oit_pass"),
2925                    color_attachments: &[
2926                        Some(wgpu::RenderPassColorAttachment {
2927                            view: accum_view,
2928                            resolve_target: None,
2929                            ops: wgpu::Operations {
2930                                load: wgpu::LoadOp::Clear(wgpu::Color {
2931                                    r: 0.0,
2932                                    g: 0.0,
2933                                    b: 0.0,
2934                                    a: 0.0,
2935                                }),
2936                                store: wgpu::StoreOp::Store,
2937                            },
2938                            depth_slice: None,
2939                        }),
2940                        Some(wgpu::RenderPassColorAttachment {
2941                            view: reveal_view,
2942                            resolve_target: None,
2943                            ops: wgpu::Operations {
2944                                load: wgpu::LoadOp::Clear(wgpu::Color {
2945                                    r: 1.0,
2946                                    g: 1.0,
2947                                    b: 1.0,
2948                                    a: 1.0,
2949                                }),
2950                                store: wgpu::StoreOp::Store,
2951                            },
2952                            depth_slice: None,
2953                        }),
2954                    ],
2955                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2956                        view: hdr_depth_view,
2957                        depth_ops: Some(wgpu::Operations {
2958                            load: wgpu::LoadOp::Load, // reuse opaque depth
2959                            store: wgpu::StoreOp::Store,
2960                        }),
2961                        stencil_ops: None,
2962                    }),
2963                    timestamp_writes: None,
2964                    occlusion_query_set: None,
2965                });
2966
2967                oit_pass.set_bind_group(0, camera_bg, &[]);
2968
2969                if self.use_instancing && !self.instanced_batches.is_empty() {
2970                    let use_indirect_oit = self.gpu_culling_enabled
2971                        && self.resources.oit_instanced_cull_pipeline.is_some()
2972                        && self.resources.indirect_args_buf.is_some();
2973
2974                    if use_indirect_oit {
2975                        if let (Some(pipeline), Some(indirect_buf)) = (
2976                            &self.resources.oit_instanced_cull_pipeline,
2977                            &self.resources.indirect_args_buf,
2978                        ) {
2979                            oit_pass.set_pipeline(pipeline);
2980                            oit_pass.set_bind_group(
2981                                2,
2982                                &self.resources.deform.dummy_bind_group,
2983                                &[],
2984                            );
2985                            for (batch_global_idx, batch) in
2986                                self.instanced_batches.iter().enumerate()
2987                            {
2988                                if !batch.is_transparent {
2989                                    continue;
2990                                }
2991                                let Some(mesh) = self.resources.mesh_store.get(batch.mesh_id)
2992                                else {
2993                                    continue;
2994                                };
2995                                let mat_key = (
2996                                    batch.texture_id.unwrap_or(u64::MAX),
2997                                    batch.normal_map_id.unwrap_or(u64::MAX),
2998                                    batch.ao_map_id.unwrap_or(u64::MAX),
2999                                );
3000                                let Some(inst_tex_bg) =
3001                                    self.resources.instance_cull_bind_groups.get(&mat_key)
3002                                else {
3003                                    continue;
3004                                };
3005                                oit_pass.set_bind_group(1, inst_tex_bg, &[]);
3006                                oit_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
3007                                oit_pass.set_index_buffer(
3008                                    mesh.index_buffer.slice(..),
3009                                    wgpu::IndexFormat::Uint32,
3010                                );
3011                                oit_pass.draw_indexed_indirect(
3012                                    indirect_buf,
3013                                    batch_global_idx as u64 * 20,
3014                                );
3015                            }
3016                        }
3017                    } else if let Some(ref pipeline) = self.resources.oit_instanced_pipeline {
3018                        oit_pass.set_pipeline(pipeline);
3019                        oit_pass.set_bind_group(2, &self.resources.deform.dummy_bind_group, &[]);
3020                        for batch in &self.instanced_batches {
3021                            if !batch.is_transparent {
3022                                continue;
3023                            }
3024                            let Some(mesh) = self.resources.mesh_store.get(batch.mesh_id) else {
3025                                continue;
3026                            };
3027                            let mat_key = (
3028                                batch.texture_id.unwrap_or(u64::MAX),
3029                                batch.normal_map_id.unwrap_or(u64::MAX),
3030                                batch.ao_map_id.unwrap_or(u64::MAX),
3031                            );
3032                            let Some(inst_tex_bg) =
3033                                self.resources.instance_bind_groups.get(&mat_key)
3034                            else {
3035                                continue;
3036                            };
3037                            oit_pass.set_bind_group(1, inst_tex_bg, &[]);
3038                            oit_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
3039                            oit_pass.set_index_buffer(
3040                                mesh.index_buffer.slice(..),
3041                                wgpu::IndexFormat::Uint32,
3042                            );
3043                            oit_pass.draw_indexed(
3044                                0..mesh.index_count,
3045                                0,
3046                                batch.instance_offset..batch.instance_offset + batch.instance_count,
3047                            );
3048                        }
3049                    }
3050
3051                    // Transparent excluded items (two-sided, active attribute, matcap) are not
3052                    // in any instanced batch, so the instanced OIT loop above skips them.
3053                    // Render them here individually so they are not invisible at opacity < 1.
3054                    if let Some(ref pipeline) = self.resources.oit_pipeline {
3055                        oit_pass.set_pipeline(pipeline);
3056                        for (item_idx, item) in scene_items.iter().enumerate() {
3057                            if item.settings.hidden
3058                                || (item.settings.opacity >= 1.0 && !item.material.is_blend())
3059                            {
3060                                continue;
3061                            }
3062                            let is_skinned = self
3063                                .resources
3064                                .deform
3065                                .has_per_instance_deform_data(item.mesh_id, item.deform_instance);
3066                            if !is_skinned
3067                                && item.active_attribute.is_none()
3068                                && !item.material.is_two_sided()
3069                                && item.material.matcap_id().is_none()
3070                            {
3071                                continue;
3072                            }
3073                            let Some(mesh) = self.resources.mesh_store.get(item.mesh_id) else {
3074                                continue;
3075                            };
3076                            let deform_bg = self
3077                                .resources
3078                                .deform
3079                                .instance_bind_group_for(item.mesh_id, item.deform_instance);
3080                            let obj_bg = self
3081                                .per_item_object_bind_groups
3082                                .get(item_idx)
3083                                .and_then(|opt| opt.as_ref())
3084                                .unwrap_or(&mesh.object_bind_group);
3085                            oit_pass.set_bind_group(1, obj_bg, &[]);
3086                            oit_pass.set_bind_group(2, deform_bg, &[]);
3087                            oit_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
3088                            oit_pass.set_index_buffer(
3089                                mesh.index_buffer.slice(..),
3090                                wgpu::IndexFormat::Uint32,
3091                            );
3092                            oit_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
3093                        }
3094                    }
3095                } else if let Some(ref pipeline) = self.resources.oit_pipeline {
3096                    oit_pass.set_pipeline(pipeline);
3097                    for (item_idx, item) in scene_items.iter().enumerate() {
3098                        if item.settings.hidden
3099                            || (item.settings.opacity >= 1.0 && !item.material.is_blend())
3100                        {
3101                            continue;
3102                        }
3103                        let Some(mesh) = self.resources.mesh_store.get(item.mesh_id) else {
3104                            continue;
3105                        };
3106                        let deform_bg = self
3107                            .resources
3108                            .deform
3109                            .instance_bind_group_for(item.mesh_id, item.deform_instance);
3110                        let obj_bg = self
3111                            .per_item_object_bind_groups
3112                            .get(item_idx)
3113                            .and_then(|opt| opt.as_ref())
3114                            .unwrap_or(&mesh.object_bind_group);
3115                        oit_pass.set_bind_group(1, obj_bg, &[]);
3116                        oit_pass.set_bind_group(2, deform_bg, &[]);
3117                        oit_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
3118                        oit_pass.set_index_buffer(
3119                            mesh.index_buffer.slice(..),
3120                            wgpu::IndexFormat::Uint32,
3121                        );
3122                        oit_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
3123                    }
3124                }
3125
3126                // -----------------------------------------------------------
3127                // Projected tetrahedra transparent volume meshes.
3128                // Items with `transparency: Some(_)` route here; opaque items
3129                // already drew through the surface pipeline.
3130                // -----------------------------------------------------------
3131                let any_transparent = frame
3132                    .scene
3133                    .volume_meshes
3134                    .iter()
3135                    .any(|i| !i.settings.hidden && i.transparency.is_some());
3136                if any_transparent {
3137                    self.resources.ensure_pt_pipeline(device);
3138                    // Pre-build LUT bind groups for every unique colourmap the
3139                    // current frame's transparent items reference, so the draw
3140                    // loop below can borrow them immutably from the cache.
3141                    for item in &frame.scene.volume_meshes {
3142                        if item.settings.hidden || item.transparency.is_none() {
3143                            continue;
3144                        }
3145                        self.resources
3146                            .ensure_pt_lut_bind_group(device, item.colourmap_id);
3147                    }
3148                    if let Some(pipeline) = self.resources.pt_pipeline.as_ref() {
3149                        oit_pass.set_pipeline(pipeline);
3150                        oit_pass.set_bind_group(0, camera_bg, &[]);
3151                        let resources = &self.resources;
3152                        for item in &frame.scene.volume_meshes {
3153                            if item.settings.hidden {
3154                                continue;
3155                            }
3156                            let Some(transparency) = item.transparency else {
3157                                continue;
3158                            };
3159                            if item.settings.wireframe || frame.viewport.wireframe_mode {
3160                                continue;
3161                            }
3162                            let Some(pt_id) = item.projected_tet_id else {
3163                                continue;
3164                            };
3165                            let Some(gpu) = resources.projected_tet_store.get(pt_id.0) else {
3166                                continue;
3167                            };
3168                            let (scalar_min, scalar_max) =
3169                                item.scalar_range.unwrap_or(gpu.scalar_range);
3170                            let uniform = crate::resources::ProjectedTetUniform {
3171                                density: transparency.density,
3172                                scalar_min,
3173                                scalar_max,
3174                                threshold_min: transparency.threshold_min,
3175                                threshold_max: transparency.threshold_max,
3176                                unlit: if item.settings.unlit { 1 } else { 0 },
3177                                opacity: item.settings.opacity,
3178                                _pad: 0.0,
3179                            };
3180                            queue.write_buffer(
3181                                &gpu.uniform_buffer,
3182                                0,
3183                                bytemuck::bytes_of(&uniform),
3184                            );
3185                            // Look up the pre-built LUT bind group (cache miss
3186                            // is impossible because we populated it above).
3187                            let lut_bg = item
3188                                .colourmap_id
3189                                .and_then(|id| {
3190                                    resources
3191                                        .colourmap_views
3192                                        .get(id.0)
3193                                        .and(resources.pt_lut_bind_groups.get(&id.0))
3194                                })
3195                                .or(resources.pt_fallback_lut_bind_group.as_ref());
3196                            let Some(lut_bg) = lut_bg else { continue };
3197                            oit_pass.set_bind_group(2, lut_bg, &[]);
3198                            for chunk in &gpu.chunks {
3199                                oit_pass.set_bind_group(1, &chunk.bind_group, &[]);
3200                                oit_pass.draw(0..6, 0..chunk.tet_count);
3201                            }
3202                        }
3203                    }
3204                }
3205
3206                // Item-type plugin transparent draws.
3207                self.dispatch_plugin_paint_transparent(&mut oit_pass, frame);
3208            }
3209        }
3210
3211        // -----------------------------------------------------------------------
3212        // OIT composite pass: blend accum/reveal into HDR buffer.
3213        // Only executes when transparent items were present.
3214        // -----------------------------------------------------------------------
3215        if has_transparent {
3216            if let (Some(pipeline), Some(bg)) = (
3217                self.resources.oit_composite_pipeline.as_ref(),
3218                slot_hdr.oit_composite_bind_group.as_ref(),
3219            ) {
3220                let hdr_view = &slot_hdr.hdr_view;
3221                let mut composite_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3222                    label: Some("oit_composite_pass"),
3223                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3224                        view: hdr_view,
3225                        resolve_target: None,
3226                        ops: wgpu::Operations {
3227                            load: wgpu::LoadOp::Load,
3228                            store: wgpu::StoreOp::Store,
3229                        },
3230                        depth_slice: None,
3231                    })],
3232                    depth_stencil_attachment: None,
3233                    timestamp_writes: None,
3234                    occlusion_query_set: None,
3235                });
3236                composite_pass.set_pipeline(pipeline);
3237                composite_pass.set_bind_group(0, bg, &[]);
3238                composite_pass.draw(0..3, 0..1);
3239            }
3240        }
3241
3242        // -----------------------------------------------------------------------
3243        // Scatter-volume pass: render each visible volume as an instanced
3244        // draw whose vertex shader projects the world bounding box and emits
3245        // a screen-space quad. Per-volume draws accumulate into
3246        // `raw_current` in back-to-front order. Optional temporal-resolve
3247        // and composite passes follow.
3248        // -----------------------------------------------------------------------
3249        if !self.prepared_scatter_volumes.is_empty() {
3250            let scatter = &frame.effects.scatter;
3251            let [sw, sh] = slot_hdr.scene_size;
3252            let want_downsample = scatter.downsample;
3253            let (tw, th) = if want_downsample {
3254                ((sw / 2).max(1), (sh / 2).max(1))
3255            } else {
3256                (sw.max(1), sh.max(1))
3257            };
3258
3259            // Grow the per-viewport scatter state slot lazily.
3260            while self.scatter_viewport_states.len() <= vp_idx {
3261                self.scatter_viewport_states.push(None);
3262            }
3263
3264            // Pipelines.
3265            self.resources
3266                .ensure_scatter_pipeline(device, wgpu::TextureFormat::Rgba16Float);
3267            self.resources
3268                .ensure_scatter_composite_pipeline(device, wgpu::TextureFormat::Rgba16Float);
3269            self.resources
3270                .ensure_scatter_temporal_resolve_pipeline(device);
3271
3272            // (Re)allocate intermediates if requested size / mode changed.
3273            let needs_alloc = match self.scatter_viewport_states[vp_idx].as_ref() {
3274                None => true,
3275                Some(s) => s.size != [tw, th] || s.downsampled != want_downsample,
3276            };
3277            if needs_alloc {
3278                let make_tex = |label: &str| {
3279                    device.create_texture(&wgpu::TextureDescriptor {
3280                        label: Some(label),
3281                        size: wgpu::Extent3d {
3282                            width: tw,
3283                            height: th,
3284                            depth_or_array_layers: 1,
3285                        },
3286                        mip_level_count: 1,
3287                        sample_count: 1,
3288                        dimension: wgpu::TextureDimension::D2,
3289                        format: wgpu::TextureFormat::Rgba16Float,
3290                        usage: wgpu::TextureUsages::RENDER_ATTACHMENT
3291                            | wgpu::TextureUsages::TEXTURE_BINDING,
3292                        view_formats: &[],
3293                    })
3294                };
3295                let raw_tex = make_tex("scatter_raw_current");
3296                let hist_a_tex = make_tex("scatter_history_a");
3297                let hist_b_tex = make_tex("scatter_history_b");
3298                let raw_view = raw_tex.create_view(&wgpu::TextureViewDescriptor::default());
3299                let hist_a_view = hist_a_tex.create_view(&wgpu::TextureViewDescriptor::default());
3300                let hist_b_view = hist_b_tex.create_view(&wgpu::TextureViewDescriptor::default());
3301                let composite_bg_raw = self.resources.make_scatter_composite_bg(device, &raw_view);
3302                let composite_bg_history_a = self
3303                    .resources
3304                    .make_scatter_composite_bg(device, &hist_a_view);
3305                let composite_bg_history_b = self
3306                    .resources
3307                    .make_scatter_composite_bg(device, &hist_b_view);
3308                let temporal_resolve_bg_read_a = self.resources.make_scatter_temporal_resolve_bg(
3309                    device,
3310                    queue,
3311                    &raw_view,
3312                    &hist_a_view,
3313                    &slot_hdr.hdr_depth_only_view,
3314                );
3315                let temporal_resolve_bg_read_b = self.resources.make_scatter_temporal_resolve_bg(
3316                    device,
3317                    queue,
3318                    &raw_view,
3319                    &hist_b_view,
3320                    &slot_hdr.hdr_depth_only_view,
3321                );
3322                self.scatter_viewport_states[vp_idx] =
3323                    Some(crate::resources::ScatterViewportState {
3324                        raw_current_texture: raw_tex,
3325                        raw_current_view: raw_view,
3326                        history_a_texture: hist_a_tex,
3327                        history_a_view: hist_a_view,
3328                        history_b_texture: hist_b_tex,
3329                        history_b_view: hist_b_view,
3330                        composite_bg_raw,
3331                        composite_bg_history_a,
3332                        composite_bg_history_b,
3333                        temporal_resolve_bg_read_a,
3334                        temporal_resolve_bg_read_b,
3335                        size: [tw, th],
3336                        downsampled: want_downsample,
3337                        parity: 0,
3338                        history_valid: false,
3339                        prev_view_proj: [[0.0; 4]; 4],
3340                        refraction_source_texture: None,
3341                        refraction_source_view: None,
3342                        refraction_source_bg: None,
3343                        refraction_blit_bg: None,
3344                        refraction_source_size: [0, 0],
3345                    });
3346            }
3347
3348            let (parity, history_valid, prev_view_proj) = {
3349                let s = self.scatter_viewport_states[vp_idx].as_ref().unwrap();
3350                (s.parity, s.history_valid, s.prev_view_proj)
3351            };
3352
3353            // -----------------------------------------------------------------
3354            // Refraction pass: distort the scene colour behind each refractive
3355            // volume. Runs before the scatter pass so absorption and
3356            // in-scattering apply on top of the shimmered scene. Skipped
3357            // entirely when no volume has `refraction = Some(...)`.
3358            // -----------------------------------------------------------------
3359            if !self.prepared_refraction_volumes.is_empty() {
3360                self.resources
3361                    .ensure_scatter_refraction_pipeline(device, wgpu::TextureFormat::Rgba16Float);
3362                self.resources.ensure_scatter_refraction_blit_pipeline(
3363                    device,
3364                    wgpu::TextureFormat::Rgba16Float,
3365                );
3366
3367                // Allocate (or resize) the refraction source texture at HDR
3368                // resolution. Replaces the per-viewport entry's view when the
3369                // scene size changes.
3370                let need_realloc = {
3371                    let s = self.scatter_viewport_states[vp_idx].as_ref().unwrap();
3372                    s.refraction_source_view.is_none() || s.refraction_source_size != [sw, sh]
3373                };
3374                if need_realloc {
3375                    let src_tex = device.create_texture(&wgpu::TextureDescriptor {
3376                        label: Some("scatter_refraction_source"),
3377                        size: wgpu::Extent3d {
3378                            width: sw.max(1),
3379                            height: sh.max(1),
3380                            depth_or_array_layers: 1,
3381                        },
3382                        mip_level_count: 1,
3383                        sample_count: 1,
3384                        dimension: wgpu::TextureDimension::D2,
3385                        format: wgpu::TextureFormat::Rgba16Float,
3386                        usage: wgpu::TextureUsages::RENDER_ATTACHMENT
3387                            | wgpu::TextureUsages::TEXTURE_BINDING,
3388                        view_formats: &[],
3389                    });
3390                    let src_view = src_tex.create_view(&wgpu::TextureViewDescriptor::default());
3391                    let blit_bg = self
3392                        .resources
3393                        .make_scatter_composite_bg(device, &slot_hdr.hdr_view);
3394                    let source_bg = self.resources.make_scatter_refraction_source_bg(
3395                        device,
3396                        &src_view,
3397                        &slot_hdr.hdr_depth_only_view,
3398                    );
3399                    let s = self.scatter_viewport_states[vp_idx].as_mut().unwrap();
3400                    s.refraction_source_texture = Some(src_tex);
3401                    s.refraction_source_view = Some(src_view);
3402                    s.refraction_source_bg = Some(source_bg);
3403                    s.refraction_blit_bg = Some(blit_bg);
3404                    s.refraction_source_size = [sw, sh];
3405                }
3406
3407                let time_seconds = self.start_instant.elapsed().as_secs_f32();
3408                let n_ref = self.resources.write_scatter_refraction_per_volume_buffer(
3409                    device,
3410                    queue,
3411                    &self.prepared_refraction_volumes,
3412                    time_seconds,
3413                );
3414                let ref_stride = self.resources.scatter_refraction_per_volume_stride();
3415
3416                if n_ref > 0 {
3417                    let s = self.scatter_viewport_states[vp_idx].as_ref().unwrap();
3418                    let src_view = s.refraction_source_view.as_ref().unwrap();
3419                    let blit_bg = s.refraction_blit_bg.as_ref().unwrap();
3420                    let source_bg = s.refraction_source_bg.as_ref().unwrap();
3421
3422                    // Blit-copy HDR -> refraction source (replace blend).
3423                    if let Some(blit_pipeline) =
3424                        self.resources.scatter_refraction_blit_pipeline.as_ref()
3425                    {
3426                        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3427                            label: Some("scatter_refraction_blit_pass"),
3428                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3429                                view: src_view,
3430                                resolve_target: None,
3431                                ops: wgpu::Operations {
3432                                    load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
3433                                    store: wgpu::StoreOp::Store,
3434                                },
3435                                depth_slice: None,
3436                            })],
3437                            depth_stencil_attachment: None,
3438                            timestamp_writes: None,
3439                            occlusion_query_set: None,
3440                        });
3441                        pass.set_pipeline(blit_pipeline);
3442                        pass.set_bind_group(0, blit_bg, &[]);
3443                        pass.draw(0..3, 0..1);
3444                    }
3445
3446                    // Per-volume distortion pass: write distorted samples into
3447                    // the HDR target.
3448                    if let (Some(pipeline), Some(per_vol_bg)) = (
3449                        self.resources.scatter_refraction_pipeline.as_ref(),
3450                        self.resources.scatter_refraction_per_volume_bg.as_ref(),
3451                    ) {
3452                        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3453                            label: Some("scatter_refraction_pass"),
3454                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3455                                view: &slot_hdr.hdr_view,
3456                                resolve_target: None,
3457                                ops: wgpu::Operations {
3458                                    load: wgpu::LoadOp::Load,
3459                                    store: wgpu::StoreOp::Store,
3460                                },
3461                                depth_slice: None,
3462                            })],
3463                            depth_stencil_attachment: None,
3464                            timestamp_writes: None,
3465                            occlusion_query_set: None,
3466                        });
3467                        pass.set_pipeline(pipeline);
3468                        pass.set_bind_group(0, camera_bg, &[]);
3469                        pass.set_bind_group(2, source_bg, &[]);
3470                        for i in 0..n_ref {
3471                            let dyn_offset = i * ref_stride;
3472                            pass.set_bind_group(1, per_vol_bg, &[dyn_offset]);
3473                            pass.draw(0..6, 0..1);
3474                        }
3475                    }
3476                }
3477            }
3478
3479            // Per-volume uniform buffer.
3480            self.resources.clear_scatter_per_volume_tex_cache();
3481            let n = self.resources.write_scatter_per_volume_buffer(
3482                device,
3483                queue,
3484                &self.prepared_scatter_volumes,
3485            );
3486            let stride = self.resources.scatter_per_volume_stride();
3487
3488            // Per-frame uniform + frame bind group.
3489            let time_seconds = self.start_instant.elapsed().as_secs_f32();
3490            let global_steps = scatter.quality.default_steps();
3491            let depth_token = (vp_idx as u64).wrapping_mul(1_000_003)
3492                ^ (tw as u64).wrapping_mul(7919)
3493                ^ (th as u64).wrapping_mul(31);
3494            self.resources.write_scatter_frame_uniform(
3495                device,
3496                queue,
3497                &slot_hdr.hdr_depth_only_view,
3498                depth_token,
3499                time_seconds,
3500                global_steps,
3501                scatter.blue_noise_jitter,
3502                self.frame_counter,
3503            );
3504
3505            // Temporal uniform (used by the optional resolve pass).
3506            if scatter.temporal {
3507                self.resources.write_scatter_temporal_uniform(
3508                    device,
3509                    queue,
3510                    prev_view_proj,
3511                    scatter.temporal_blend,
3512                    history_valid,
3513                );
3514            }
3515
3516            // Pre-build per-volume texture bind groups (cached by ids).
3517            let mut per_vol_tex_bgs: Vec<wgpu::BindGroup> = Vec::with_capacity(n as usize);
3518            for (volume, _, _) in self.prepared_scatter_volumes.iter().take(n as usize) {
3519                let (lut_id, density_id) =
3520                    crate::resources::ViewportGpuResources::scatter_volume_tex_ids(volume);
3521                let bg = self
3522                    .resources
3523                    .ensure_scatter_per_volume_tex_bg(device, queue, lut_id, density_id);
3524                per_vol_tex_bgs.push(bg);
3525            }
3526
3527            if n > 0 {
3528                let s = self.scatter_viewport_states[vp_idx].as_ref().unwrap();
3529                let raw_view = &s.raw_current_view;
3530                if let (Some(pipeline), Some(per_vol_bg), Some(frame_bg)) = (
3531                    self.resources.scatter_pipeline.as_ref(),
3532                    self.resources.scatter_per_volume_bg.as_ref(),
3533                    self.resources.scatter_frame_bg.as_ref(),
3534                ) {
3535                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3536                        label: Some("scatter_volume_pass"),
3537                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3538                            view: raw_view,
3539                            resolve_target: None,
3540                            ops: wgpu::Operations {
3541                                load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
3542                                store: wgpu::StoreOp::Store,
3543                            },
3544                            depth_slice: None,
3545                        })],
3546                        depth_stencil_attachment: None,
3547                        timestamp_writes: None,
3548                        occlusion_query_set: None,
3549                    });
3550                    pass.set_pipeline(pipeline);
3551                    pass.set_bind_group(0, camera_bg, &[]);
3552                    pass.set_bind_group(3, frame_bg, &[]);
3553                    for i in 0..n {
3554                        let dyn_offset = i * stride;
3555                        pass.set_bind_group(1, per_vol_bg, &[dyn_offset]);
3556                        pass.set_bind_group(2, &per_vol_tex_bgs[i as usize], &[]);
3557                        pass.draw(0..6, 0..1);
3558                    }
3559                }
3560
3561                // Optional temporal-resolve pass.
3562                let source_for_composite: &wgpu::BindGroup = if scatter.temporal {
3563                    let s = self.scatter_viewport_states[vp_idx].as_ref().unwrap();
3564                    // parity = which slot to write next (history_new).
3565                    let (resolve_bg, target_view, source_composite) = if parity == 0 {
3566                        // history_prev = B, write history_new = A.
3567                        (
3568                            &s.temporal_resolve_bg_read_b,
3569                            &s.history_a_view,
3570                            &s.composite_bg_history_a,
3571                        )
3572                    } else {
3573                        (
3574                            &s.temporal_resolve_bg_read_a,
3575                            &s.history_b_view,
3576                            &s.composite_bg_history_b,
3577                        )
3578                    };
3579                    if let Some(resolve_pipeline) =
3580                        self.resources.scatter_temporal_resolve_pipeline.as_ref()
3581                    {
3582                        let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3583                            label: Some("scatter_temporal_resolve_pass"),
3584                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3585                                view: target_view,
3586                                resolve_target: None,
3587                                ops: wgpu::Operations {
3588                                    load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
3589                                    store: wgpu::StoreOp::Store,
3590                                },
3591                                depth_slice: None,
3592                            })],
3593                            depth_stencil_attachment: None,
3594                            timestamp_writes: None,
3595                            occlusion_query_set: None,
3596                        });
3597                        pass.set_pipeline(resolve_pipeline);
3598                        pass.set_bind_group(0, resolve_bg, &[]);
3599                        pass.draw(0..3, 0..1);
3600                    }
3601                    source_composite
3602                } else {
3603                    &s.composite_bg_raw
3604                };
3605
3606                // Composite pass: source -> HDR with premultiplied alpha-over.
3607                if let Some(composite_pipeline) = self.resources.scatter_composite_pipeline.as_ref()
3608                {
3609                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3610                        label: Some("scatter_composite_pass"),
3611                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3612                            view: &slot_hdr.hdr_view,
3613                            resolve_target: None,
3614                            ops: wgpu::Operations {
3615                                load: wgpu::LoadOp::Load,
3616                                store: wgpu::StoreOp::Store,
3617                            },
3618                            depth_slice: None,
3619                        })],
3620                        depth_stencil_attachment: None,
3621                        timestamp_writes: None,
3622                        occlusion_query_set: None,
3623                    });
3624                    pass.set_pipeline(composite_pipeline);
3625                    pass.set_bind_group(0, source_for_composite, &[]);
3626                    pass.draw(0..3, 0..1);
3627                }
3628
3629                // Advance ping-pong + history for next frame.
3630                let s = self.scatter_viewport_states[vp_idx].as_mut().unwrap();
3631                s.prev_view_proj = frame.camera.render_camera.view_proj().to_cols_array_2d();
3632                s.parity = 1 - s.parity;
3633                s.history_valid = scatter.temporal;
3634            } else if let Some(s) = self.scatter_viewport_states[vp_idx].as_mut() {
3635                s.history_valid = false;
3636            }
3637        }
3638
3639        // -----------------------------------------------------------------------
3640        // Surface LIC passes.
3641        // Pass 1: render each LIC mesh into lic_vector_texture (Rgba8Unorm).
3642        // Pass 2: advect fullscreen triangle into lic_output_texture (R8Unorm).
3643        // -----------------------------------------------------------------------
3644        if !self.lic_gpu_data.is_empty() {
3645            if let (Some(surface_pipeline), Some(advect_pipeline)) = (
3646                self.resources.lic_surface_pipeline.as_ref(),
3647                self.resources.lic_advect_pipeline.as_ref(),
3648            ) {
3649                let camera_bg = &slot.camera_bind_group;
3650                // Pass 1: surface vector pass (clears lic_vector_texture first).
3651                {
3652                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3653                        label: Some("lic_surface_pass"),
3654                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3655                            view: &slot_hdr.lic_vector_view,
3656                            resolve_target: None,
3657                            ops: wgpu::Operations {
3658                                load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
3659                                store: wgpu::StoreOp::Store,
3660                            },
3661                            depth_slice: None,
3662                        })],
3663                        depth_stencil_attachment: None,
3664                        timestamp_writes: None,
3665                        occlusion_query_set: None,
3666                    });
3667                    pass.set_pipeline(surface_pipeline);
3668                    pass.set_bind_group(0, camera_bg, &[]);
3669                    for gpu in &self.lic_gpu_data {
3670                        let Some(mesh) = self.resources.mesh_store.get(gpu.mesh_id) else {
3671                            continue;
3672                        };
3673                        let Some(vec_buf) =
3674                            mesh.vector_attribute_buffers.get(&gpu.vector_attribute)
3675                        else {
3676                            continue;
3677                        };
3678                        pass.set_bind_group(1, &gpu.bind_group, &[]);
3679                        pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
3680                        pass.set_vertex_buffer(1, vec_buf.slice(..));
3681                        pass.set_index_buffer(
3682                            mesh.index_buffer.slice(..),
3683                            wgpu::IndexFormat::Uint32,
3684                        );
3685                        pass.draw_indexed(0..mesh.index_count, 0, 0..1);
3686                    }
3687                }
3688                // Pass 2: advect pass (fullscreen, writes LIC intensity to lic_output_texture).
3689                {
3690                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3691                        label: Some("lic_advect_pass"),
3692                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3693                            view: &slot_hdr.lic_output_view,
3694                            resolve_target: None,
3695                            ops: wgpu::Operations {
3696                                load: wgpu::LoadOp::Clear(wgpu::Color {
3697                                    r: 0.5,
3698                                    g: 0.0,
3699                                    b: 0.0,
3700                                    a: 1.0,
3701                                }),
3702                                store: wgpu::StoreOp::Store,
3703                            },
3704                            depth_slice: None,
3705                        })],
3706                        depth_stencil_attachment: None,
3707                        timestamp_writes: None,
3708                        occlusion_query_set: None,
3709                    });
3710                    pass.set_pipeline(advect_pipeline);
3711                    pass.set_bind_group(0, &slot_hdr.lic_advect_bind_group, &[]);
3712                    pass.draw(0..3, 0..1);
3713                }
3714            }
3715        }
3716
3717        // -----------------------------------------------------------------------
3718        // Outline composite pass (HDR path): blit offscreen outline onto hdr_view.
3719        // Runs after the HDR scene pass (which has depth+stencil) in a separate
3720        // pass with no depth attachment, so the composite pipeline is compatible.
3721        // -----------------------------------------------------------------------
3722        if !slot.outline_object_buffers.is_empty()
3723            || !slot.splat_outline_buffers.is_empty()
3724            || !slot.streamtube_outline_items.is_empty()
3725            || !slot.tube_outline_items.is_empty()
3726            || !slot.ribbon_outline_items.is_empty()
3727            || !slot.polyline_outline_indices.is_empty()
3728            || !slot.volume_outline_indices.is_empty()
3729            || !slot.glyph_outline_indices.is_empty()
3730            || !slot.tensor_glyph_outline_indices.is_empty()
3731            || !slot.sprite_outline_indices.is_empty()
3732            || !slot.raw_geom_outline_buffers.is_empty()
3733            || !slot.screen_rect_outline_buffers.is_empty()
3734            || !slot.implicit_outline_indices.is_empty()
3735            || !slot.mc_outline_data.is_empty()
3736        {
3737            // Prefer the HDR-format pipeline; fall back to LDR single-sample.
3738            let hdr_pipeline = self
3739                .resources
3740                .outline_composite_pipeline_hdr
3741                .as_ref()
3742                .or(self.resources.outline_composite_pipeline_single.as_ref());
3743            if let Some(pipeline) = hdr_pipeline {
3744                let bg = &slot_hdr.outline_composite_bind_group;
3745                let hdr_view = &slot_hdr.hdr_view;
3746                let hdr_depth_view = &slot_hdr.hdr_depth_view;
3747                let mut outline_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3748                    label: Some("hdr_outline_composite_pass"),
3749                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3750                        view: hdr_view,
3751                        resolve_target: None,
3752                        ops: wgpu::Operations {
3753                            load: wgpu::LoadOp::Load,
3754                            store: wgpu::StoreOp::Store,
3755                        },
3756                        depth_slice: None,
3757                    })],
3758                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
3759                        view: hdr_depth_view,
3760                        depth_ops: Some(wgpu::Operations {
3761                            load: wgpu::LoadOp::Load,
3762                            store: wgpu::StoreOp::Store,
3763                        }),
3764                        stencil_ops: None,
3765                    }),
3766                    timestamp_writes: None,
3767                    occlusion_query_set: None,
3768                });
3769                outline_pass.set_pipeline(pipeline);
3770                outline_pass.set_bind_group(0, bg, &[]);
3771                outline_pass.draw(0..3, 0..1);
3772            }
3773        }
3774
3775        // Effect throttling. Flag was computed in prepare() so that
3776        // FrameStats reports exactly what fired rather than an approximation.
3777        let throttle_effects = self.degradation_effects_throttled;
3778
3779        // -----------------------------------------------------------------------
3780        // SSAO pass.
3781        // -----------------------------------------------------------------------
3782        if pp.ssao && !throttle_effects {
3783            if let Some(ssao_pipeline) = &self.resources.ssao_pipeline {
3784                {
3785                    let mut ssao_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3786                        label: Some("ssao_pass"),
3787                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3788                            view: &slot_hdr.ssao_view,
3789                            resolve_target: None,
3790                            ops: wgpu::Operations {
3791                                load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
3792                                store: wgpu::StoreOp::Store,
3793                            },
3794                            depth_slice: None,
3795                        })],
3796                        depth_stencil_attachment: None,
3797                        timestamp_writes: None,
3798                        occlusion_query_set: None,
3799                    });
3800                    ssao_pass.set_pipeline(ssao_pipeline);
3801                    ssao_pass.set_bind_group(0, &slot_hdr.ssao_bg, &[]);
3802                    ssao_pass.draw(0..3, 0..1);
3803                }
3804
3805                // SSAO blur pass.
3806                if let Some(ssao_blur_pipeline) = &self.resources.ssao_blur_pipeline {
3807                    let mut ssao_blur_pass =
3808                        encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3809                            label: Some("ssao_blur_pass"),
3810                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3811                                view: &slot_hdr.ssao_blur_view,
3812                                resolve_target: None,
3813                                ops: wgpu::Operations {
3814                                    load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
3815                                    store: wgpu::StoreOp::Store,
3816                                },
3817                                depth_slice: None,
3818                            })],
3819                            depth_stencil_attachment: None,
3820                            timestamp_writes: None,
3821                            occlusion_query_set: None,
3822                        });
3823                    ssao_blur_pass.set_pipeline(ssao_blur_pipeline);
3824                    ssao_blur_pass.set_bind_group(0, &slot_hdr.ssao_blur_bg, &[]);
3825                    ssao_blur_pass.draw(0..3, 0..1);
3826                }
3827            }
3828        }
3829
3830        // -----------------------------------------------------------------------
3831        // Contact shadow pass.
3832        // -----------------------------------------------------------------------
3833        if pp.contact_shadows && !throttle_effects {
3834            if let Some(cs_pipeline) = &self.resources.contact_shadow_pipeline {
3835                let mut cs_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3836                    label: Some("contact_shadow_pass"),
3837                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3838                        view: &slot_hdr.contact_shadow_view,
3839                        resolve_target: None,
3840                        depth_slice: None,
3841                        ops: wgpu::Operations {
3842                            load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
3843                            store: wgpu::StoreOp::Store,
3844                        },
3845                    })],
3846                    depth_stencil_attachment: None,
3847                    timestamp_writes: None,
3848                    occlusion_query_set: None,
3849                });
3850                cs_pass.set_pipeline(cs_pipeline);
3851                cs_pass.set_bind_group(0, &slot_hdr.contact_shadow_bg, &[]);
3852                cs_pass.draw(0..3, 0..1);
3853            }
3854        }
3855
3856        // -----------------------------------------------------------------------
3857        // Bloom passes.
3858        // -----------------------------------------------------------------------
3859        if pp.bloom && !throttle_effects {
3860            // Threshold pass: extract bright pixels into bloom_threshold_texture.
3861            if let Some(bloom_threshold_pipeline) = &self.resources.bloom_threshold_pipeline {
3862                {
3863                    let mut threshold_pass =
3864                        encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3865                            label: Some("bloom_threshold_pass"),
3866                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3867                                view: &slot_hdr.bloom_threshold_view,
3868                                resolve_target: None,
3869                                ops: wgpu::Operations {
3870                                    load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
3871                                    store: wgpu::StoreOp::Store,
3872                                },
3873                                depth_slice: None,
3874                            })],
3875                            depth_stencil_attachment: None,
3876                            timestamp_writes: None,
3877                            occlusion_query_set: None,
3878                        });
3879                    threshold_pass.set_pipeline(bloom_threshold_pipeline);
3880                    threshold_pass.set_bind_group(0, &slot_hdr.bloom_threshold_bg, &[]);
3881                    threshold_pass.draw(0..3, 0..1);
3882                }
3883
3884                // 4 ping-pong H+V blur passes for a wide glow.
3885                // Pass 1: threshold -> ping -> pong. Passes 2-4: pong -> ping -> pong.
3886                if let Some(blur_pipeline) = &self.resources.bloom_blur_pipeline {
3887                    let blur_h_bg = &slot_hdr.bloom_blur_h_bg;
3888                    let blur_h_pong_bg = &slot_hdr.bloom_blur_h_pong_bg;
3889                    let blur_v_bg = &slot_hdr.bloom_blur_v_bg;
3890                    let bloom_ping_view = &slot_hdr.bloom_ping_view;
3891                    let bloom_pong_view = &slot_hdr.bloom_pong_view;
3892                    const BLUR_ITERATIONS: usize = 4;
3893                    for i in 0..BLUR_ITERATIONS {
3894                        // H pass: pass 0 reads threshold, subsequent passes read pong.
3895                        let h_bg = if i == 0 { blur_h_bg } else { blur_h_pong_bg };
3896                        {
3897                            let mut h_pass =
3898                                encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3899                                    label: Some("bloom_blur_h_pass"),
3900                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3901                                        view: bloom_ping_view,
3902                                        resolve_target: None,
3903                                        ops: wgpu::Operations {
3904                                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
3905                                            store: wgpu::StoreOp::Store,
3906                                        },
3907                                        depth_slice: None,
3908                                    })],
3909                                    depth_stencil_attachment: None,
3910                                    timestamp_writes: None,
3911                                    occlusion_query_set: None,
3912                                });
3913                            h_pass.set_pipeline(blur_pipeline);
3914                            h_pass.set_bind_group(0, h_bg, &[]);
3915                            h_pass.draw(0..3, 0..1);
3916                        }
3917                        // V pass: ping -> pong.
3918                        {
3919                            let mut v_pass =
3920                                encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3921                                    label: Some("bloom_blur_v_pass"),
3922                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3923                                        view: bloom_pong_view,
3924                                        resolve_target: None,
3925                                        ops: wgpu::Operations {
3926                                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
3927                                            store: wgpu::StoreOp::Store,
3928                                        },
3929                                        depth_slice: None,
3930                                    })],
3931                                    depth_stencil_attachment: None,
3932                                    timestamp_writes: None,
3933                                    occlusion_query_set: None,
3934                                });
3935                            v_pass.set_pipeline(blur_pipeline);
3936                            v_pass.set_bind_group(0, blur_v_bg, &[]);
3937                            v_pass.draw(0..3, 0..1);
3938                        }
3939                    }
3940                }
3941            }
3942        }
3943
3944        // -----------------------------------------------------------------------
3945        // Depth of field pass: HDR + depth -> dof_texture (when enabled).
3946        // -----------------------------------------------------------------------
3947        if pp.dof_enabled && !throttle_effects {
3948            if let Some(dof_pipeline) = &self.resources.dof_pipeline {
3949                let mut dof_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3950                    label: Some("dof_pass"),
3951                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3952                        view: &slot_hdr.dof_view,
3953                        resolve_target: None,
3954                        depth_slice: None,
3955                        ops: wgpu::Operations {
3956                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
3957                            store: wgpu::StoreOp::Store,
3958                        },
3959                    })],
3960                    depth_stencil_attachment: None,
3961                    timestamp_writes: None,
3962                    occlusion_query_set: None,
3963                });
3964                dof_pass.set_pipeline(dof_pipeline);
3965                dof_pass.set_bind_group(0, &slot_hdr.dof_bg, &[]);
3966                dof_pass.draw(0..3, 0..1);
3967            }
3968        }
3969
3970        // -----------------------------------------------------------------------
3971        // Tone map pass: HDR + bloom + AO -> tone-mapped LDR.
3972        //
3973        // When render_scale < 1.0 the entire post-process chain runs at scene
3974        // resolution. The result lands in upscale_view (scene-res) and is then
3975        // upscale-blitted to output_view at native resolution.
3976        // -----------------------------------------------------------------------
3977        let use_fxaa = pp.fxaa;
3978        let use_hdr_upscale = slot_hdr.upscale_bind_group.is_some();
3979        if let Some(tone_map_pipeline) = &self.resources.tone_map_pipeline {
3980            let tone_target: &wgpu::TextureView = if use_fxaa {
3981                &slot_hdr.fxaa_view
3982            } else if use_hdr_upscale {
3983                slot_hdr.upscale_view.as_ref().unwrap()
3984            } else {
3985                output_view
3986            };
3987            let mut tone_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
3988                label: Some("tone_map_pass"),
3989                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
3990                    view: tone_target,
3991                    resolve_target: None,
3992                    ops: wgpu::Operations {
3993                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
3994                        store: wgpu::StoreOp::Store,
3995                    },
3996                    depth_slice: None,
3997                })],
3998                depth_stencil_attachment: None,
3999                timestamp_writes: None,
4000                occlusion_query_set: None,
4001            });
4002            tone_pass.set_pipeline(tone_map_pipeline);
4003            tone_pass.set_bind_group(0, &slot_hdr.tone_map_bind_group, &[]);
4004            tone_pass.draw(0..3, 0..1);
4005        }
4006
4007        // -----------------------------------------------------------------------
4008        // FXAA pass: fxaa_texture -> upscale_view (scaled) or output_view (1:1).
4009        // -----------------------------------------------------------------------
4010        if use_fxaa {
4011            if let Some(fxaa_pipeline) = &self.resources.fxaa_pipeline {
4012                let fxaa_target: &wgpu::TextureView = if use_hdr_upscale {
4013                    slot_hdr.upscale_view.as_ref().unwrap()
4014                } else {
4015                    output_view
4016                };
4017                let mut fxaa_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
4018                    label: Some("fxaa_pass"),
4019                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
4020                        view: fxaa_target,
4021                        resolve_target: None,
4022                        ops: wgpu::Operations {
4023                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
4024                            store: wgpu::StoreOp::Store,
4025                        },
4026                        depth_slice: None,
4027                    })],
4028                    depth_stencil_attachment: None,
4029                    timestamp_writes: None,
4030                    occlusion_query_set: None,
4031                });
4032                fxaa_pass.set_pipeline(fxaa_pipeline);
4033                fxaa_pass.set_bind_group(0, &slot_hdr.fxaa_bind_group, &[]);
4034                fxaa_pass.draw(0..3, 0..1);
4035            }
4036        }
4037
4038        // -----------------------------------------------------------------------
4039        // HDR upscale pass: blit scene-resolution post-processed output to native
4040        // output_view. Only runs when render_scale < 1.0.
4041        // -----------------------------------------------------------------------
4042        if use_hdr_upscale {
4043            let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
4044            if let Some(upscale_bg) = &slot_hdr.upscale_bind_group {
4045                if let Some(pipeline) = &self.resources.dyn_res_upscale_pipeline {
4046                    let mut upscale_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
4047                        label: Some("hdr_upscale_pass"),
4048                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
4049                            view: output_view,
4050                            resolve_target: None,
4051                            ops: wgpu::Operations {
4052                                load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
4053                                store: wgpu::StoreOp::Store,
4054                            },
4055                            depth_slice: None,
4056                        })],
4057                        depth_stencil_attachment: None,
4058                        timestamp_writes: None,
4059                        occlusion_query_set: None,
4060                    });
4061                    upscale_pass.set_pipeline(pipeline);
4062                    upscale_pass.set_bind_group(0, upscale_bg, &[]);
4063                    upscale_pass.draw(0..3, 0..1);
4064                }
4065            }
4066        }
4067
4068        // Depth blit pass: when render_scale < 1.0, the scene depth texture is
4069        // smaller than the output surface. Copy it to output_depth_texture (native
4070        // resolution) so the post-tone-map passes below can attach output_depth_view
4071        // alongside output_view without a size mismatch. Skipped when render_scale
4072        // is 1.0 (output_depth_view is just a second view of hdr_depth_texture).
4073        {
4074            let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
4075            if let Some(blit_bg) = &slot_hdr.depth_blit_bind_group {
4076                if let Some(blit_pipeline) = &self.resources.depth_blit_pipeline {
4077                    let mut blit_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
4078                        label: Some("depth_blit_pass"),
4079                        color_attachments: &[],
4080                        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
4081                            view: &slot_hdr.output_depth_view,
4082                            depth_ops: Some(wgpu::Operations {
4083                                load: wgpu::LoadOp::Clear(1.0),
4084                                store: wgpu::StoreOp::Store,
4085                            }),
4086                            stencil_ops: None,
4087                        }),
4088                        timestamp_writes: None,
4089                        occlusion_query_set: None,
4090                    });
4091                    blit_pass.set_pipeline(blit_pipeline);
4092                    blit_pass.set_bind_group(0, blit_bg, &[]);
4093                    blit_pass.draw(0..3, 0..1);
4094                }
4095            }
4096        }
4097
4098        // Grid pass (HDR path): draw the existing analytical grid on the final
4099        // output after tone mapping / FXAA, reusing the scene depth buffer so
4100        // scene geometry still occludes the grid exactly as in the LDR path.
4101        if frame.viewport.show_grid {
4102            let slot = &self.viewport_slots[vp_idx];
4103            let slot_hdr = slot.hdr.as_ref().unwrap();
4104            let grid_bg = &slot.grid_bind_group;
4105            let mut grid_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
4106                label: Some("hdr_grid_pass"),
4107                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
4108                    view: output_view,
4109                    resolve_target: None,
4110                    ops: wgpu::Operations {
4111                        load: wgpu::LoadOp::Load,
4112                        store: wgpu::StoreOp::Store,
4113                    },
4114                    depth_slice: None,
4115                })],
4116                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
4117                    view: &slot_hdr.output_depth_view,
4118                    depth_ops: Some(wgpu::Operations {
4119                        load: wgpu::LoadOp::Load,
4120                        store: wgpu::StoreOp::Store,
4121                    }),
4122                    stencil_ops: None,
4123                }),
4124                timestamp_writes: None,
4125                occlusion_query_set: None,
4126            });
4127            grid_pass.set_pipeline(&self.resources.grid_pipeline);
4128            grid_pass.set_bind_group(0, grid_bg, &[]);
4129            grid_pass.draw(0..3, 0..1);
4130        }
4131
4132        // Ground plane pass (HDR path): drawn after grid, before editor overlays.
4133        // Uses the scene depth buffer for correct occlusion against geometry.
4134        if !matches!(
4135            frame.effects.ground_plane.mode,
4136            crate::renderer::types::GroundPlaneMode::None
4137        ) {
4138            let slot = &self.viewport_slots[vp_idx];
4139            let slot_hdr = slot.hdr.as_ref().unwrap();
4140            let mut gp_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
4141                label: Some("hdr_ground_plane_pass"),
4142                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
4143                    view: output_view,
4144                    resolve_target: None,
4145                    ops: wgpu::Operations {
4146                        load: wgpu::LoadOp::Load,
4147                        store: wgpu::StoreOp::Store,
4148                    },
4149                    depth_slice: None,
4150                })],
4151                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
4152                    view: &slot_hdr.output_depth_view,
4153                    depth_ops: Some(wgpu::Operations {
4154                        load: wgpu::LoadOp::Load,
4155                        store: wgpu::StoreOp::Store,
4156                    }),
4157                    stencil_ops: None,
4158                }),
4159                timestamp_writes: None,
4160                occlusion_query_set: None,
4161            });
4162            gp_pass.set_pipeline(&self.resources.ground_plane_pipeline);
4163            gp_pass.set_bind_group(0, &self.resources.ground_plane_bind_group, &[]);
4164            gp_pass.draw(0..3, 0..1);
4165        }
4166
4167        // Screen-space image overlay pass (HDR path).
4168        // Must run before the editor overlay and axes passes because those
4169        // discard hdr_depth_view (StoreOp::Discard). The DC pipeline compares
4170        // per-pixel image depth against the scene depth buffer; if the buffer
4171        // has been discarded, Metal returns zeros and all DC fragments fail.
4172        // Plain overlay items (depth_compare: Always) are unaffected by depth,
4173        // but ordering them here keeps depth-composite correct.
4174        if !self.screen_image_gpu_data.is_empty() {
4175            if let Some(overlay_pipeline) = &self.resources.screen_image_pipeline {
4176                let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
4177                let dc_pipeline = self.resources.screen_image_dc_pipeline.as_ref();
4178                let mut img_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
4179                    label: Some("screen_image_pass"),
4180                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
4181                        view: output_view,
4182                        resolve_target: None,
4183                        ops: wgpu::Operations {
4184                            load: wgpu::LoadOp::Load,
4185                            store: wgpu::StoreOp::Store,
4186                        },
4187                        depth_slice: None,
4188                    })],
4189                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
4190                        view: &slot_hdr.output_depth_view,
4191                        depth_ops: Some(wgpu::Operations {
4192                            load: wgpu::LoadOp::Load,
4193                            store: wgpu::StoreOp::Discard,
4194                        }),
4195                        stencil_ops: None,
4196                    }),
4197                    timestamp_writes: None,
4198                    occlusion_query_set: None,
4199                });
4200                for gpu in &self.screen_image_gpu_data {
4201                    if let (Some(dc_bg), Some(dc_pipe)) = (&gpu.depth_bind_group, dc_pipeline) {
4202                        img_pass.set_pipeline(dc_pipe);
4203                        img_pass.set_bind_group(0, dc_bg, &[]);
4204                    } else {
4205                        img_pass.set_pipeline(overlay_pipeline);
4206                        img_pass.set_bind_group(0, &gpu.bind_group, &[]);
4207                    }
4208                    img_pass.draw(0..6, 0..1);
4209                }
4210            }
4211        }
4212
4213        // Editor overlay pass (HDR path): draw viewport/editor overlays on the
4214        // final output after tone mapping / FXAA, reusing the scene depth
4215        // buffer so depth-tested helpers still behave correctly.
4216        {
4217            let slot = &self.viewport_slots[vp_idx];
4218            let slot_hdr = slot.hdr.as_ref().unwrap();
4219            let has_editor_overlays = (frame.interaction.gizmo_model.is_some()
4220                && slot.gizmo_index_count > 0)
4221                || !slot.constraint_line_buffers.is_empty()
4222                || !slot.clip_plane_fill_buffers.is_empty()
4223                || !slot.clip_plane_line_buffers.is_empty()
4224                || !slot.xray_object_buffers.is_empty();
4225            if has_editor_overlays {
4226                let camera_bg = &slot.camera_bind_group;
4227                let mut overlay_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
4228                    label: Some("hdr_editor_overlay_pass"),
4229                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
4230                        view: output_view,
4231                        resolve_target: None,
4232                        ops: wgpu::Operations {
4233                            load: wgpu::LoadOp::Load,
4234                            store: wgpu::StoreOp::Store,
4235                        },
4236                        depth_slice: None,
4237                    })],
4238                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
4239                        view: &slot_hdr.output_depth_view,
4240                        depth_ops: Some(wgpu::Operations {
4241                            load: wgpu::LoadOp::Load,
4242                            store: wgpu::StoreOp::Discard,
4243                        }),
4244                        stencil_ops: None,
4245                    }),
4246                    timestamp_writes: None,
4247                    occlusion_query_set: None,
4248                });
4249
4250                if frame.interaction.gizmo_model.is_some() && slot.gizmo_index_count > 0 {
4251                    overlay_pass.set_pipeline(&self.resources.gizmo_pipeline);
4252                    overlay_pass.set_bind_group(0, camera_bg, &[]);
4253                    overlay_pass.set_bind_group(1, &slot.gizmo_bind_group, &[]);
4254                    overlay_pass.set_vertex_buffer(0, slot.gizmo_vertex_buffer.slice(..));
4255                    overlay_pass.set_index_buffer(
4256                        slot.gizmo_index_buffer.slice(..),
4257                        wgpu::IndexFormat::Uint32,
4258                    );
4259                    overlay_pass.draw_indexed(0..slot.gizmo_index_count, 0, 0..1);
4260                }
4261
4262                if !slot.constraint_line_buffers.is_empty() {
4263                    overlay_pass.set_pipeline(&self.resources.overlay_line_pipeline);
4264                    overlay_pass.set_bind_group(0, camera_bg, &[]);
4265                    for (vbuf, ibuf, index_count, _ubuf, bg) in &slot.constraint_line_buffers {
4266                        overlay_pass.set_bind_group(1, bg, &[]);
4267                        overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
4268                        overlay_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
4269                        overlay_pass.draw_indexed(0..*index_count, 0, 0..1);
4270                    }
4271                }
4272
4273                if !slot.clip_plane_fill_buffers.is_empty() {
4274                    overlay_pass.set_pipeline(&self.resources.overlay_pipeline);
4275                    overlay_pass.set_bind_group(0, camera_bg, &[]);
4276                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.clip_plane_fill_buffers {
4277                        overlay_pass.set_bind_group(1, bg, &[]);
4278                        overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
4279                        overlay_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
4280                        overlay_pass.draw_indexed(0..*idx_count, 0, 0..1);
4281                    }
4282                }
4283
4284                if !slot.clip_plane_line_buffers.is_empty() {
4285                    overlay_pass.set_pipeline(&self.resources.overlay_line_pipeline);
4286                    overlay_pass.set_bind_group(0, camera_bg, &[]);
4287                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.clip_plane_line_buffers {
4288                        overlay_pass.set_bind_group(1, bg, &[]);
4289                        overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
4290                        overlay_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
4291                        overlay_pass.draw_indexed(0..*idx_count, 0, 0..1);
4292                    }
4293                }
4294
4295                if !slot.xray_object_buffers.is_empty() {
4296                    overlay_pass.set_pipeline(&self.resources.xray_pipeline);
4297                    overlay_pass.set_bind_group(0, camera_bg, &[]);
4298                    for (mesh_id, _buf, bg) in &slot.xray_object_buffers {
4299                        let Some(mesh) = self.resources.mesh_store.get(*mesh_id) else {
4300                            continue;
4301                        };
4302                        overlay_pass.set_bind_group(1, bg, &[]);
4303                        overlay_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
4304                        overlay_pass.set_index_buffer(
4305                            mesh.index_buffer.slice(..),
4306                            wgpu::IndexFormat::Uint32,
4307                        );
4308                        overlay_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
4309                    }
4310                }
4311            }
4312        }
4313
4314        // Axes indicator pass (HDR path): draw in screen space on the final
4315        // output after tone mapping / FXAA so it stays visible in PBR mode.
4316        if frame.viewport.show_axes_indicator {
4317            let slot = &self.viewport_slots[vp_idx];
4318            if slot.axes_vertex_count > 0 {
4319                let slot_hdr = slot.hdr.as_ref().unwrap();
4320                let mut axes_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
4321                    label: Some("hdr_axes_pass"),
4322                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
4323                        view: output_view,
4324                        resolve_target: None,
4325                        ops: wgpu::Operations {
4326                            load: wgpu::LoadOp::Load,
4327                            store: wgpu::StoreOp::Store,
4328                        },
4329                        depth_slice: None,
4330                    })],
4331                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
4332                        view: &slot_hdr.output_depth_view,
4333                        depth_ops: Some(wgpu::Operations {
4334                            load: wgpu::LoadOp::Load,
4335                            store: wgpu::StoreOp::Discard,
4336                        }),
4337                        stencil_ops: None,
4338                    }),
4339                    timestamp_writes: None,
4340                    occlusion_query_set: None,
4341                });
4342                axes_pass.set_pipeline(&self.resources.axes_pipeline);
4343                axes_pass.set_vertex_buffer(0, slot.axes_vertex_buffer.slice(..));
4344                axes_pass.draw(0..slot.axes_vertex_count, 0..1);
4345            }
4346        }
4347
4348        // Overlay shapes, rects, labels, scalar bars, rulers, and overlay images (HDR path): drawn last.
4349        let has_overlay = self.overlay_shape_gpu_data.is_some()
4350            || self.overlay_rect_gpu_data.is_some()
4351            || self.label_gpu_data.is_some()
4352            || self.scalar_bar_gpu_data.is_some()
4353            || self.ruler_gpu_data.is_some()
4354            || self.loading_bar_gpu_data.is_some()
4355            || !self.overlay_image_gpu_data.is_empty();
4356
4357        // HDR backdrop blur: the tonemapped scene is on an intermediate so we
4358        // can sample it for the blur. When blur is needed we redirect the
4359        // tonemapped output to a managed intermediate, blur it, then draw
4360        // overlays there and blit to output_view at the end.
4361        let needs_hdr_blur = self.has_backdrop_blur_shapes();
4362        let hdr_blur_bg: Option<wgpu::BindGroup> = if needs_hdr_blur && has_overlay {
4363            self.ensure_backdrop_blur_state(device, w.max(1), h.max(1));
4364            // Blit output_view content to the intermediate so we have a
4365            // samplable copy. We already have the tonemapped result on
4366            // output_view. Unfortunately surface textures can't be sampled,
4367            // so we blit to our intermediate first.
4368            //
4369            // Actually for HDR: the tone-map wrote to output_view (or
4370            // upscale_view). We need the scene in a samplable texture. The
4371            // HDR colour texture (hdr_colour_view) is samplable but it's HDR.
4372            // For simplicity, use the HDR colour texture as the blur source;
4373            // the blur result will be HDR-ish but clamped by the LDR target
4374            // format of the blur textures. This looks acceptable in practice.
4375            let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
4376            let source = &slot_hdr.hdr_view;
4377            let spread = self
4378                .overlay_shape_gpu_data
4379                .as_ref()
4380                .map(|d| d.max_blur_radius)
4381                .unwrap_or(8.0);
4382            Some(self.run_backdrop_blur(&mut encoder, device, queue, source, spread))
4383        } else {
4384            None
4385        };
4386
4387        if has_overlay {
4388            let hdr_depth_view = &self.viewport_slots[vp_idx]
4389                .hdr
4390                .as_ref()
4391                .unwrap()
4392                .output_depth_view;
4393            let mut overlay_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
4394                label: Some("overlay_pass"),
4395                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
4396                    view: output_view,
4397                    resolve_target: None,
4398                    ops: wgpu::Operations {
4399                        load: wgpu::LoadOp::Load,
4400                        store: wgpu::StoreOp::Store,
4401                    },
4402                    depth_slice: None,
4403                })],
4404                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
4405                    view: hdr_depth_view,
4406                    depth_ops: Some(wgpu::Operations {
4407                        load: wgpu::LoadOp::Load,
4408                        store: wgpu::StoreOp::Discard,
4409                    }),
4410                    stencil_ops: None,
4411                }),
4412                timestamp_writes: None,
4413                occlusion_query_set: None,
4414            });
4415            // Blur backdrop shapes drawn first (behind normal shapes).
4416            if let Some(ref bg) = hdr_blur_bg {
4417                self.draw_blur_shapes(&mut overlay_pass, bg);
4418            }
4419            // SDF overlay shapes (HDR path).
4420            if let Some(ref sd) = self.overlay_shape_gpu_data {
4421                if sd.vertex_count > 0 {
4422                    if let Some(pipeline) = &self.resources.overlay_shape_pipeline {
4423                        if let Some(vbuf) = &sd.vertex_buf {
4424                            overlay_pass.set_pipeline(pipeline);
4425                            overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
4426                            overlay_pass.draw(0..sd.vertex_count, 0..1);
4427                        }
4428                    }
4429                }
4430                if !sd.tex_batches.is_empty() {
4431                    if let Some(pipeline) = &self.resources.overlay_shape_tex_pipeline {
4432                        overlay_pass.set_pipeline(pipeline);
4433                        for batch in &sd.tex_batches {
4434                            overlay_pass.set_bind_group(0, &batch.bind_group, &[]);
4435                            overlay_pass.set_vertex_buffer(0, batch.vertex_buf.slice(..));
4436                            overlay_pass.draw(0..batch.vertex_count, 0..1);
4437                        }
4438                    }
4439                }
4440            }
4441            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
4442                overlay_pass.set_pipeline(pipeline);
4443                if let Some(ref rr) = self.overlay_rect_gpu_data {
4444                    overlay_pass.set_bind_group(0, &rr.bind_group, &[]);
4445                    overlay_pass.set_vertex_buffer(0, rr.vertex_buf.slice(..));
4446                    overlay_pass.draw(0..rr.vertex_count, 0..1);
4447                }
4448                if let Some(ref ld) = self.label_gpu_data {
4449                    overlay_pass.set_bind_group(0, &ld.bind_group, &[]);
4450                    overlay_pass.set_vertex_buffer(0, ld.vertex_buf.slice(..));
4451                    overlay_pass.draw(0..ld.vertex_count, 0..1);
4452                }
4453                if let Some(ref sb) = self.scalar_bar_gpu_data {
4454                    overlay_pass.set_bind_group(0, &sb.bind_group, &[]);
4455                    overlay_pass.set_vertex_buffer(0, sb.vertex_buf.slice(..));
4456                    overlay_pass.draw(0..sb.vertex_count, 0..1);
4457                }
4458                if let Some(ref rd) = self.ruler_gpu_data {
4459                    overlay_pass.set_bind_group(0, &rd.bind_group, &[]);
4460                    overlay_pass.set_vertex_buffer(0, rd.vertex_buf.slice(..));
4461                    overlay_pass.draw(0..rd.vertex_count, 0..1);
4462                }
4463                if let Some(ref lb) = self.loading_bar_gpu_data {
4464                    overlay_pass.set_bind_group(0, &lb.bind_group, &[]);
4465                    overlay_pass.set_vertex_buffer(0, lb.vertex_buf.slice(..));
4466                    overlay_pass.draw(0..lb.vertex_count, 0..1);
4467                }
4468            }
4469            // Overlay images drawn last inside the overlay pass.
4470            if !self.overlay_image_gpu_data.is_empty() {
4471                if let Some(pipeline) = &self.resources.screen_image_pipeline {
4472                    overlay_pass.set_pipeline(pipeline);
4473                    for gpu in &self.overlay_image_gpu_data {
4474                        overlay_pass.set_bind_group(0, &gpu.bind_group, &[]);
4475                        overlay_pass.draw(0..6, 0..1);
4476                    }
4477                }
4478            }
4479        }
4480
4481        // Resolve timestamp queries -> staging buffer (HDR path).
4482        if let (Some(qs), Some(res_buf), Some(stg_buf)) = (
4483            self.ts_query_set.as_ref(),
4484            self.ts_resolve_buf.as_ref(),
4485            self.ts_staging_buf.as_ref(),
4486        ) {
4487            encoder.resolve_query_set(qs, 0..2, res_buf, 0);
4488            encoder.copy_buffer_to_buffer(res_buf, 0, stg_buf, 0, 16);
4489            self.ts_needs_readback = true;
4490        }
4491
4492        encoder.finish()
4493    }
4494
4495    /// Render a frame to an offscreen texture and return raw RGBA bytes.
4496    ///
4497    /// Creates a temporary [`wgpu::Texture`] render target of the given dimensions,
4498    /// runs all render passes (shadow, scene, post-processing) into it via
4499    /// [`render()`](Self::render), then copies the result back to CPU memory.
4500    ///
4501    /// No OS window or [`wgpu::Surface`] is required. The caller is responsible for
4502    /// initialising the wgpu adapter with `compatible_surface: None` and for
4503    /// constructing a valid [`FrameData`] (including `viewport_size` matching
4504    /// `width`/`height`).
4505    ///
4506    /// Returns `width * height * 4` bytes in RGBA8 layout. The caller encodes to
4507    /// PNG/EXR independently : no image codec dependency in this crate.
4508    pub fn render_offscreen(
4509        &mut self,
4510        device: &wgpu::Device,
4511        queue: &wgpu::Queue,
4512        frame: &FrameData,
4513        width: u32,
4514        height: u32,
4515    ) -> Vec<u8> {
4516        // 1. Create offscreen texture with RENDER_ATTACHMENT | COPY_SRC usage.
4517        let target_format = self.resources.target_format;
4518        let offscreen_texture = device.create_texture(&wgpu::TextureDescriptor {
4519            label: Some("offscreen_target"),
4520            size: wgpu::Extent3d {
4521                width: width.max(1),
4522                height: height.max(1),
4523                depth_or_array_layers: 1,
4524            },
4525            mip_level_count: 1,
4526            sample_count: 1,
4527            dimension: wgpu::TextureDimension::D2,
4528            format: target_format,
4529            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
4530            view_formats: &[],
4531        });
4532
4533        // 2. Create a texture view for rendering into.
4534        let output_view = offscreen_texture.create_view(&wgpu::TextureViewDescriptor::default());
4535
4536        // 3. render() calls ensure_viewport_hdr which provides the depth-stencil buffer
4537        //    for both LDR and HDR paths, so no separate ensure_outline_target is needed.
4538
4539        // 4. Render the scene into the offscreen texture.
4540        //    The caller must set `frame.camera.viewport_size` to `[width as f32, height as f32]`
4541        //    and `frame.camera.render_camera.aspect` to `width as f32 / height as f32`
4542        //    for correct HDR target allocation and scissor rects.
4543        let cmd_buf = self.render(device, queue, &output_view, frame);
4544        queue.submit(std::iter::once(cmd_buf));
4545
4546        // 5. Copy texture -> staging buffer (wgpu requires row alignment to 256 bytes).
4547        let bytes_per_pixel = 4u32;
4548        let unpadded_row = width * bytes_per_pixel;
4549        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
4550        let padded_row = (unpadded_row + align - 1) & !(align - 1);
4551        let buffer_size = (padded_row * height.max(1)) as u64;
4552
4553        let staging_buf = device.create_buffer(&wgpu::BufferDescriptor {
4554            label: Some("offscreen_staging"),
4555            size: buffer_size,
4556            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
4557            mapped_at_creation: false,
4558        });
4559
4560        let mut copy_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
4561            label: Some("offscreen_copy_encoder"),
4562        });
4563        copy_encoder.copy_texture_to_buffer(
4564            wgpu::TexelCopyTextureInfo {
4565                texture: &offscreen_texture,
4566                mip_level: 0,
4567                origin: wgpu::Origin3d::ZERO,
4568                aspect: wgpu::TextureAspect::All,
4569            },
4570            wgpu::TexelCopyBufferInfo {
4571                buffer: &staging_buf,
4572                layout: wgpu::TexelCopyBufferLayout {
4573                    offset: 0,
4574                    bytes_per_row: Some(padded_row),
4575                    rows_per_image: Some(height.max(1)),
4576                },
4577            },
4578            wgpu::Extent3d {
4579                width: width.max(1),
4580                height: height.max(1),
4581                depth_or_array_layers: 1,
4582            },
4583        );
4584        queue.submit(std::iter::once(copy_encoder.finish()));
4585
4586        // 6. Map buffer and extract tightly-packed RGBA pixels.
4587        let (tx, rx) = std::sync::mpsc::channel();
4588        staging_buf
4589            .slice(..)
4590            .map_async(wgpu::MapMode::Read, move |result| {
4591                let _ = tx.send(result);
4592            });
4593        device
4594            .poll(wgpu::PollType::Wait {
4595                submission_index: None,
4596                timeout: Some(std::time::Duration::from_secs(5)),
4597            })
4598            .unwrap();
4599        let _ = rx.recv().unwrap_or(Err(wgpu::BufferAsyncError));
4600
4601        let mut pixels: Vec<u8> = Vec::with_capacity((width * height * 4) as usize);
4602        {
4603            let mapped = staging_buf.slice(..).get_mapped_range();
4604            let data: &[u8] = &mapped;
4605            if padded_row == unpadded_row {
4606                // No padding : copy entire slice directly.
4607                pixels.extend_from_slice(data);
4608            } else {
4609                // Strip row padding.
4610                for row in 0..height as usize {
4611                    let start = row * padded_row as usize;
4612                    let end = start + unpadded_row as usize;
4613                    pixels.extend_from_slice(&data[start..end]);
4614                }
4615            }
4616        }
4617        staging_buf.unmap();
4618
4619        // 7. Swizzle BGRA -> RGBA if the format stores bytes in BGRA order.
4620        let is_bgra = matches!(
4621            target_format,
4622            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
4623        );
4624        if is_bgra {
4625            for pixel in pixels.chunks_exact_mut(4) {
4626                pixel.swap(0, 2); // B ↔ R
4627            }
4628        }
4629
4630        pixels
4631    }
4632
4633    // ------------------------------------------------------------------
4634    // Backdrop blur helpers
4635    // ------------------------------------------------------------------
4636
4637    /// Ensure the backdrop blur state textures exist at the right size.
4638    fn ensure_backdrop_blur_state(&mut self, device: &wgpu::Device, w: u32, h: u32) {
4639        let need_recreate = match &self.backdrop_blur_state {
4640            Some(s) => s.size != [w, h] || s.format != self.resources.target_format,
4641            None => true,
4642        };
4643        if !need_recreate {
4644            return;
4645        }
4646
4647        let format = self.resources.target_format;
4648        let blur_w = (w / 2).max(1);
4649        let blur_h = (h / 2).max(1);
4650
4651        let intermediate_texture = device.create_texture(&wgpu::TextureDescriptor {
4652            label: Some("backdrop_intermediate"),
4653            size: wgpu::Extent3d {
4654                width: w,
4655                height: h,
4656                depth_or_array_layers: 1,
4657            },
4658            mip_level_count: 1,
4659            sample_count: 1,
4660            dimension: wgpu::TextureDimension::D2,
4661            format,
4662            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
4663            view_formats: &[],
4664        });
4665        let intermediate_view = intermediate_texture.create_view(&Default::default());
4666
4667        let make_blur_tex = |label: &str| {
4668            let t = device.create_texture(&wgpu::TextureDescriptor {
4669                label: Some(label),
4670                size: wgpu::Extent3d {
4671                    width: blur_w,
4672                    height: blur_h,
4673                    depth_or_array_layers: 1,
4674                },
4675                mip_level_count: 1,
4676                sample_count: 1,
4677                dimension: wgpu::TextureDimension::D2,
4678                format,
4679                usage: wgpu::TextureUsages::RENDER_ATTACHMENT
4680                    | wgpu::TextureUsages::TEXTURE_BINDING,
4681                view_formats: &[],
4682            });
4683            let v = t.create_view(&Default::default());
4684            (t, v)
4685        };
4686        let (blur_a_texture, blur_a_view) = make_blur_tex("backdrop_blur_a");
4687        let (blur_b_texture, blur_b_view) = make_blur_tex("backdrop_blur_b");
4688
4689        self.backdrop_blur_state = Some(crate::resources::BackdropBlurState {
4690            intermediate_texture,
4691            intermediate_view,
4692            blur_a_texture,
4693            blur_a_view,
4694            blur_b_texture,
4695            blur_b_view,
4696            size: [w, h],
4697            format,
4698        });
4699    }
4700
4701    /// Run the backdrop blur pipeline: blit scene to half-res, then H blur, then V blur.
4702    /// Returns the bind group that can be used to draw blur overlay shapes with the
4703    /// texture pipeline.
4704    fn run_backdrop_blur(
4705        &self,
4706        encoder: &mut wgpu::CommandEncoder,
4707        device: &wgpu::Device,
4708        _queue: &wgpu::Queue,
4709        source_view: &wgpu::TextureView,
4710        spread: f32,
4711    ) -> wgpu::BindGroup {
4712        let bs = self.backdrop_blur_state.as_ref().unwrap();
4713        let blur_bgl = self.resources.backdrop_blur_bgl.as_ref().unwrap();
4714        let blur_sampler = self.resources.backdrop_blur_sampler.as_ref().unwrap();
4715        let blur_pipeline = self.resources.backdrop_blur_pipeline.as_ref().unwrap();
4716        // Reuse dyn_res blit pipeline and BGL for the downsample pass.
4717        let blit_pipeline = self.resources.dyn_res_upscale_pipeline.as_ref().unwrap();
4718        let blit_bgl = self.resources.dyn_res_upscale_bgl.as_ref().unwrap();
4719        let blit_sampler = self.resources.dyn_res_linear_sampler.as_ref().unwrap();
4720
4721        // Step 1: downsample source -> blur_a (half-res) using bilinear blit.
4722        let downsample_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
4723            label: Some("backdrop_downsample_bg"),
4724            layout: blit_bgl,
4725            entries: &[
4726                wgpu::BindGroupEntry {
4727                    binding: 0,
4728                    resource: wgpu::BindingResource::TextureView(source_view),
4729                },
4730                wgpu::BindGroupEntry {
4731                    binding: 1,
4732                    resource: wgpu::BindingResource::Sampler(blit_sampler),
4733                },
4734            ],
4735        });
4736        {
4737            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
4738                label: Some("backdrop_downsample"),
4739                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
4740                    view: &bs.blur_a_view,
4741                    resolve_target: None,
4742                    ops: wgpu::Operations {
4743                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
4744                        store: wgpu::StoreOp::Store,
4745                    },
4746                    depth_slice: None,
4747                })],
4748                depth_stencil_attachment: None,
4749                timestamp_writes: None,
4750                occlusion_query_set: None,
4751            });
4752            pass.set_pipeline(blit_pipeline);
4753            pass.set_bind_group(0, &downsample_bg, &[]);
4754            pass.draw(0..3, 0..1);
4755        }
4756
4757        // Spread scaled for half-res: each texel covers 2 screen pixels.
4758        let effective_spread = (spread / 2.0).max(1.0);
4759
4760        // Step 2: horizontal blur: blur_a -> blur_b.
4761        let h_uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
4762            label: Some("blur_h_uniform"),
4763            contents: bytemuck::cast_slice(&[1u32, effective_spread.to_bits(), 0u32, 0u32]),
4764            usage: wgpu::BufferUsages::UNIFORM,
4765        });
4766        let h_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
4767            label: Some("blur_h_bg"),
4768            layout: blur_bgl,
4769            entries: &[
4770                wgpu::BindGroupEntry {
4771                    binding: 0,
4772                    resource: wgpu::BindingResource::TextureView(&bs.blur_a_view),
4773                },
4774                wgpu::BindGroupEntry {
4775                    binding: 1,
4776                    resource: wgpu::BindingResource::Sampler(blur_sampler),
4777                },
4778                wgpu::BindGroupEntry {
4779                    binding: 2,
4780                    resource: h_uniform.as_entire_binding(),
4781                },
4782            ],
4783        });
4784        {
4785            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
4786                label: Some("backdrop_blur_h"),
4787                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
4788                    view: &bs.blur_b_view,
4789                    resolve_target: None,
4790                    ops: wgpu::Operations {
4791                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
4792                        store: wgpu::StoreOp::Store,
4793                    },
4794                    depth_slice: None,
4795                })],
4796                depth_stencil_attachment: None,
4797                timestamp_writes: None,
4798                occlusion_query_set: None,
4799            });
4800            pass.set_pipeline(blur_pipeline);
4801            pass.set_bind_group(0, &h_bg, &[]);
4802            pass.draw(0..3, 0..1);
4803        }
4804
4805        // Step 3: vertical blur: blur_b -> blur_a.
4806        let v_uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
4807            label: Some("blur_v_uniform"),
4808            contents: bytemuck::cast_slice(&[0u32, effective_spread.to_bits(), 0u32, 0u32]),
4809            usage: wgpu::BufferUsages::UNIFORM,
4810        });
4811        let v_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
4812            label: Some("blur_v_bg"),
4813            layout: blur_bgl,
4814            entries: &[
4815                wgpu::BindGroupEntry {
4816                    binding: 0,
4817                    resource: wgpu::BindingResource::TextureView(&bs.blur_b_view),
4818                },
4819                wgpu::BindGroupEntry {
4820                    binding: 1,
4821                    resource: wgpu::BindingResource::Sampler(blur_sampler),
4822                },
4823                wgpu::BindGroupEntry {
4824                    binding: 2,
4825                    resource: v_uniform.as_entire_binding(),
4826                },
4827            ],
4828        });
4829        {
4830            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
4831                label: Some("backdrop_blur_v"),
4832                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
4833                    view: &bs.blur_a_view,
4834                    resolve_target: None,
4835                    ops: wgpu::Operations {
4836                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
4837                        store: wgpu::StoreOp::Store,
4838                    },
4839                    depth_slice: None,
4840                })],
4841                depth_stencil_attachment: None,
4842                timestamp_writes: None,
4843                occlusion_query_set: None,
4844            });
4845            pass.set_pipeline(blur_pipeline);
4846            pass.set_bind_group(0, &v_bg, &[]);
4847            pass.draw(0..3, 0..1);
4848        }
4849
4850        // Build the bind group for overlay shape drawing. Uses the overlay_shape_tex
4851        // bind group layout (texture + sampler) so blur shapes can be drawn with the
4852        // existing texture pipeline.
4853        let tex_bgl = self.resources.overlay_shape_tex_bgl.as_ref().unwrap();
4854        let tex_sampler = self.resources.overlay_shape_tex_sampler.as_ref().unwrap();
4855        device.create_bind_group(&wgpu::BindGroupDescriptor {
4856            label: Some("backdrop_blur_overlay_bg"),
4857            layout: tex_bgl,
4858            entries: &[
4859                wgpu::BindGroupEntry {
4860                    binding: 0,
4861                    resource: wgpu::BindingResource::TextureView(&bs.blur_a_view),
4862                },
4863                wgpu::BindGroupEntry {
4864                    binding: 1,
4865                    resource: wgpu::BindingResource::Sampler(tex_sampler),
4866                },
4867            ],
4868        })
4869    }
4870
4871    /// Returns true if the current frame has overlay shapes that need backdrop blur.
4872    fn has_backdrop_blur_shapes(&self) -> bool {
4873        self.overlay_shape_gpu_data
4874            .as_ref()
4875            .map_or(false, |sd| sd.blur_vertex_count > 0)
4876    }
4877
4878    /// Draw blur overlay shapes into the given render pass using the texture pipeline.
4879    fn draw_blur_shapes<'rp>(
4880        &'rp self,
4881        render_pass: &mut wgpu::RenderPass<'rp>,
4882        blur_bind_group: &'rp wgpu::BindGroup,
4883    ) {
4884        if let Some(ref sd) = self.overlay_shape_gpu_data {
4885            if sd.blur_vertex_count > 0 {
4886                if let (Some(pipeline), Some(vbuf)) = (
4887                    &self.resources.overlay_shape_tex_pipeline,
4888                    &sd.blur_vertex_buf,
4889                ) {
4890                    render_pass.set_pipeline(pipeline);
4891                    render_pass.set_bind_group(0, blur_bind_group, &[]);
4892                    render_pass.set_vertex_buffer(0, vbuf.slice(..));
4893                    render_pass.draw(0..sd.blur_vertex_count, 0..1);
4894                }
4895            }
4896        }
4897    }
4898}