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