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