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