Skip to main content

viewport_lib/renderer/
render.rs

1use super::*;
2
3impl ViewportRenderer {
4    /// Issue draw calls for the viewport. Call inside a `wgpu::RenderPass`.
5    ///
6    /// This method requires a `'static` render pass (as provided by egui's
7    /// `CallbackTrait`). For non-static render passes (iced, manual wgpu),
8    /// use [`paint_to`](Self::paint_to).
9    pub fn paint(&self, render_pass: &mut wgpu::RenderPass<'static>, frame: &FrameData) {
10        let vp_idx = frame.camera.viewport_index;
11        let camera_bg = self.viewport_camera_bind_group(vp_idx);
12        let grid_bg = self.viewport_grid_bind_group(vp_idx);
13        let vp_slot = self.viewport_slots.get(vp_idx);
14        emit_draw_calls!(
15            &self.resources,
16            &mut *render_pass,
17            frame,
18            self.use_instancing,
19            &self.instanced_batches,
20            camera_bg,
21            grid_bg,
22            &self.compute_filter_results,
23            vp_slot
24        );
25        emit_scivis_draw_calls!(
26            &self.resources,
27            &mut *render_pass,
28            &self.point_cloud_gpu_data,
29            &self.glyph_gpu_data,
30            &self.polyline_gpu_data,
31            &self.volume_gpu_data,
32            &self.streamtube_gpu_data,
33            camera_bg
34        );
35        // Phase 16 : GPU implicit surface (depth-writes enabled, LessEqual compare).
36        if !self.implicit_gpu_data.is_empty() {
37            if let Some(pipeline) = &self.resources.implicit_pipeline {
38                render_pass.set_pipeline(pipeline);
39                render_pass.set_bind_group(0, camera_bg, &[]);
40                for gpu in &self.implicit_gpu_data {
41                    render_pass.set_bind_group(1, &gpu.bind_group, &[]);
42                    render_pass.draw(0..6, 0..1);
43                }
44            }
45        }
46        // Phase 17 : GPU marching cubes indirect draw.
47        if !self.mc_gpu_data.is_empty() {
48            if let Some(pipeline) = &self.resources.mc_surface_pipeline {
49                render_pass.set_pipeline(pipeline);
50                render_pass.set_bind_group(0, camera_bg, &[]);
51                for mc in &self.mc_gpu_data {
52                    let vol = &self.resources.mc_volumes[mc.volume_idx];
53                    render_pass.set_bind_group(1, &mc.render_bg, &[]);
54                    for slab in &vol.slabs {
55                        render_pass.set_vertex_buffer(0, slab.vertex_buf.slice(..));
56                        render_pass.draw_indirect(&slab.indirect_buf, 0);
57                    }
58                }
59            }
60        }
61        // Sub-object highlight (LDR path) : face fill, edge lines, vertex/point sprites.
62        if let Some(sub_hl) = self.viewport_slots.get(vp_idx).and_then(|s| s.sub_highlight.as_ref()) {
63            if let (Some(fill_pl), Some(edge_pl), Some(sprite_pl)) = (
64                &self.resources.sub_highlight_fill_ldr_pipeline,
65                &self.resources.sub_highlight_edge_ldr_pipeline,
66                &self.resources.sub_highlight_sprite_ldr_pipeline,
67            ) {
68                if sub_hl.fill_vertex_count > 0 {
69                    render_pass.set_pipeline(fill_pl);
70                    render_pass.set_bind_group(0, camera_bg, &[]);
71                    render_pass.set_bind_group(1, &sub_hl.fill_bind_group, &[]);
72                    render_pass.set_vertex_buffer(0, sub_hl.fill_vertex_buf.slice(..));
73                    render_pass.draw(0..sub_hl.fill_vertex_count, 0..1);
74                }
75                if sub_hl.edge_segment_count > 0 {
76                    render_pass.set_pipeline(edge_pl);
77                    render_pass.set_bind_group(0, camera_bg, &[]);
78                    render_pass.set_bind_group(1, &sub_hl.edge_bind_group, &[]);
79                    render_pass.set_vertex_buffer(0, sub_hl.edge_vertex_buf.slice(..));
80                    render_pass.draw(0..6, 0..sub_hl.edge_segment_count);
81                }
82                if sub_hl.sprite_point_count > 0 {
83                    render_pass.set_pipeline(sprite_pl);
84                    render_pass.set_bind_group(0, camera_bg, &[]);
85                    render_pass.set_bind_group(1, &sub_hl.sprite_bind_group, &[]);
86                    render_pass.set_vertex_buffer(0, sub_hl.sprite_vertex_buf.slice(..));
87                    render_pass.draw(0..6, 0..sub_hl.sprite_point_count);
88                }
89            }
90        }
91        // Phase 10B : screen-space image overlays (always on top, no depth test).
92        if !self.screen_image_gpu_data.is_empty() {
93            if let Some(pipeline) = &self.resources.screen_image_pipeline {
94                render_pass.set_pipeline(pipeline);
95                for gpu in &self.screen_image_gpu_data {
96                    render_pass.set_bind_group(0, &gpu.bind_group, &[]);
97                    render_pass.draw(0..6, 0..1);
98                }
99            }
100        }
101        // Overlay labels (always on top, after screen images).
102        if let Some(ref ld) = self.label_gpu_data {
103            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
104                render_pass.set_pipeline(pipeline);
105                render_pass.set_bind_group(0, &ld.bind_group, &[]);
106                render_pass.set_vertex_buffer(0, ld.vertex_buf.slice(..));
107                render_pass.draw(0..ld.vertex_count, 0..1);
108            }
109        }
110        // Scalar bars (drawn after labels).
111        if let Some(ref sb) = self.scalar_bar_gpu_data {
112            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
113                render_pass.set_pipeline(pipeline);
114                render_pass.set_bind_group(0, &sb.bind_group, &[]);
115                render_pass.set_vertex_buffer(0, sb.vertex_buf.slice(..));
116                render_pass.draw(0..sb.vertex_count, 0..1);
117            }
118        }
119        // Rulers (drawn after scalar bars).
120        if let Some(ref rd) = self.ruler_gpu_data {
121            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
122                render_pass.set_pipeline(pipeline);
123                render_pass.set_bind_group(0, &rd.bind_group, &[]);
124                render_pass.set_vertex_buffer(0, rd.vertex_buf.slice(..));
125                render_pass.draw(0..rd.vertex_count, 0..1);
126            }
127        }
128        // Loading bars (drawn after rulers).
129        if let Some(ref lb) = self.loading_bar_gpu_data {
130            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
131                render_pass.set_pipeline(pipeline);
132                render_pass.set_bind_group(0, &lb.bind_group, &[]);
133                render_pass.set_vertex_buffer(0, lb.vertex_buf.slice(..));
134                render_pass.draw(0..lb.vertex_count, 0..1);
135            }
136        }
137        // Phase 7 : overlay images (OverlayFrame, drawn last, no depth test).
138        if !self.overlay_image_gpu_data.is_empty() {
139            if let Some(pipeline) = &self.resources.screen_image_pipeline {
140                render_pass.set_pipeline(pipeline);
141                for gpu in &self.overlay_image_gpu_data {
142                    render_pass.set_bind_group(0, &gpu.bind_group, &[]);
143                    render_pass.draw(0..6, 0..1);
144                }
145            }
146        }
147    }
148
149    /// Issue draw calls into a render pass with any lifetime.
150    ///
151    /// Identical to [`paint`](Self::paint) but accepts a render pass with a
152    /// non-`'static` lifetime, making it usable from iced, raw wgpu, or any
153    /// framework that creates its own render pass.
154    pub fn paint_to<'rp>(&'rp self, render_pass: &mut wgpu::RenderPass<'rp>, frame: &FrameData) {
155        let vp_idx = frame.camera.viewport_index;
156        let camera_bg = self.viewport_camera_bind_group(vp_idx);
157        let grid_bg = self.viewport_grid_bind_group(vp_idx);
158        let vp_slot = self.viewport_slots.get(vp_idx);
159        emit_draw_calls!(
160            &self.resources,
161            &mut *render_pass,
162            frame,
163            self.use_instancing,
164            &self.instanced_batches,
165            camera_bg,
166            grid_bg,
167            &self.compute_filter_results,
168            vp_slot
169        );
170        emit_scivis_draw_calls!(
171            &self.resources,
172            &mut *render_pass,
173            &self.point_cloud_gpu_data,
174            &self.glyph_gpu_data,
175            &self.polyline_gpu_data,
176            &self.volume_gpu_data,
177            &self.streamtube_gpu_data,
178            camera_bg
179        );
180        // Phase 16 : GPU implicit surface (depth-writes enabled, LessEqual compare).
181        if !self.implicit_gpu_data.is_empty() {
182            if let Some(pipeline) = &self.resources.implicit_pipeline {
183                render_pass.set_pipeline(pipeline);
184                render_pass.set_bind_group(0, camera_bg, &[]);
185                for gpu in &self.implicit_gpu_data {
186                    render_pass.set_bind_group(1, &gpu.bind_group, &[]);
187                    render_pass.draw(0..6, 0..1);
188                }
189            }
190        }
191        // Phase 17 : GPU marching cubes indirect draw.
192        if !self.mc_gpu_data.is_empty() {
193            if let Some(pipeline) = &self.resources.mc_surface_pipeline {
194                render_pass.set_pipeline(pipeline);
195                render_pass.set_bind_group(0, camera_bg, &[]);
196                for mc in &self.mc_gpu_data {
197                    let vol = &self.resources.mc_volumes[mc.volume_idx];
198                    render_pass.set_bind_group(1, &mc.render_bg, &[]);
199                    for slab in &vol.slabs {
200                        render_pass.set_vertex_buffer(0, slab.vertex_buf.slice(..));
201                        render_pass.draw_indirect(&slab.indirect_buf, 0);
202                    }
203                }
204            }
205        }
206        // Sub-object highlight (LDR path) : face fill, edge lines, vertex/point sprites.
207        if let Some(sub_hl) = self.viewport_slots.get(vp_idx).and_then(|s| s.sub_highlight.as_ref()) {
208            if let (Some(fill_pl), Some(edge_pl), Some(sprite_pl)) = (
209                &self.resources.sub_highlight_fill_ldr_pipeline,
210                &self.resources.sub_highlight_edge_ldr_pipeline,
211                &self.resources.sub_highlight_sprite_ldr_pipeline,
212            ) {
213                if sub_hl.fill_vertex_count > 0 {
214                    render_pass.set_pipeline(fill_pl);
215                    render_pass.set_bind_group(0, camera_bg, &[]);
216                    render_pass.set_bind_group(1, &sub_hl.fill_bind_group, &[]);
217                    render_pass.set_vertex_buffer(0, sub_hl.fill_vertex_buf.slice(..));
218                    render_pass.draw(0..sub_hl.fill_vertex_count, 0..1);
219                }
220                if sub_hl.edge_segment_count > 0 {
221                    render_pass.set_pipeline(edge_pl);
222                    render_pass.set_bind_group(0, camera_bg, &[]);
223                    render_pass.set_bind_group(1, &sub_hl.edge_bind_group, &[]);
224                    render_pass.set_vertex_buffer(0, sub_hl.edge_vertex_buf.slice(..));
225                    render_pass.draw(0..6, 0..sub_hl.edge_segment_count);
226                }
227                if sub_hl.sprite_point_count > 0 {
228                    render_pass.set_pipeline(sprite_pl);
229                    render_pass.set_bind_group(0, camera_bg, &[]);
230                    render_pass.set_bind_group(1, &sub_hl.sprite_bind_group, &[]);
231                    render_pass.set_vertex_buffer(0, sub_hl.sprite_vertex_buf.slice(..));
232                    render_pass.draw(0..6, 0..sub_hl.sprite_point_count);
233                }
234            }
235        }
236        // Phase 10B : screen-space image overlays (always on top, no depth test).
237        if !self.screen_image_gpu_data.is_empty() {
238            if let Some(pipeline) = &self.resources.screen_image_pipeline {
239                render_pass.set_pipeline(pipeline);
240                for gpu in &self.screen_image_gpu_data {
241                    render_pass.set_bind_group(0, &gpu.bind_group, &[]);
242                    render_pass.draw(0..6, 0..1);
243                }
244            }
245        }
246        // Overlay labels (always on top, after screen images).
247        if let Some(ref ld) = self.label_gpu_data {
248            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
249                render_pass.set_pipeline(pipeline);
250                render_pass.set_bind_group(0, &ld.bind_group, &[]);
251                render_pass.set_vertex_buffer(0, ld.vertex_buf.slice(..));
252                render_pass.draw(0..ld.vertex_count, 0..1);
253            }
254        }
255        // Scalar bars (drawn after labels).
256        if let Some(ref sb) = self.scalar_bar_gpu_data {
257            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
258                render_pass.set_pipeline(pipeline);
259                render_pass.set_bind_group(0, &sb.bind_group, &[]);
260                render_pass.set_vertex_buffer(0, sb.vertex_buf.slice(..));
261                render_pass.draw(0..sb.vertex_count, 0..1);
262            }
263        }
264        // Rulers (drawn after scalar bars).
265        if let Some(ref rd) = self.ruler_gpu_data {
266            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
267                render_pass.set_pipeline(pipeline);
268                render_pass.set_bind_group(0, &rd.bind_group, &[]);
269                render_pass.set_vertex_buffer(0, rd.vertex_buf.slice(..));
270                render_pass.draw(0..rd.vertex_count, 0..1);
271            }
272        }
273        // Loading bars (drawn after rulers).
274        if let Some(ref lb) = self.loading_bar_gpu_data {
275            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
276                render_pass.set_pipeline(pipeline);
277                render_pass.set_bind_group(0, &lb.bind_group, &[]);
278                render_pass.set_vertex_buffer(0, lb.vertex_buf.slice(..));
279                render_pass.draw(0..lb.vertex_count, 0..1);
280            }
281        }
282        // Phase 7 : overlay images (OverlayFrame, drawn last, no depth test).
283        if !self.overlay_image_gpu_data.is_empty() {
284            if let Some(pipeline) = &self.resources.screen_image_pipeline {
285                render_pass.set_pipeline(pipeline);
286                for gpu in &self.overlay_image_gpu_data {
287                    render_pass.set_bind_group(0, &gpu.bind_group, &[]);
288                    render_pass.draw(0..6, 0..1);
289                }
290            }
291        }
292    }
293
294    /// Render the scene into an intermediate dyn-res texture for the LDR callback
295    /// render path (e.g. eframe's `CallbackTrait`).
296    ///
297    /// Call from `CallbackTrait::prepare` after [`prepare`](Self::prepare), passing the
298    /// `egui_encoder`. If `current_render_scale < 1.0`, the full scene is drawn into a
299    /// scaled intermediate texture and `true` is returned. Call
300    /// [`paint_dyn_res_blit`](Self::paint_dyn_res_blit) from `CallbackTrait::paint`
301    /// instead of [`paint`](Self::paint).
302    ///
303    /// If scale is 1.0 or above, nothing is encoded and `false` is returned. Call
304    /// [`paint`](Self::paint) as normal.
305    ///
306    /// The `egui_encoder` is submitted before the surface render pass begins, so the
307    /// intermediate texture is fully written before the blit reads it.
308    pub fn prepare_ldr_dyn_res(
309        &mut self,
310        encoder: &mut wgpu::CommandEncoder,
311        device: &wgpu::Device,
312        frame: &FrameData,
313    ) -> bool {
314        if self.current_render_scale >= 1.0 - 0.001 {
315            return false;
316        }
317
318        let vp_idx = frame.camera.viewport_index;
319        let w = (frame.camera.viewport_size[0] as u32).max(1);
320        let h = (frame.camera.viewport_size[1] as u32).max(1);
321        let sw = ((w as f32 * self.current_render_scale) as u32).max(1);
322        let sh = ((h as f32 * self.current_render_scale) as u32).max(1);
323
324        self.ensure_dyn_res_target(device, vp_idx, [sw, sh], [w, h]);
325        self.resources.ensure_dyn_res_ds_pipeline(device);
326
327        let bg_color = frame.viewport.background_color.unwrap_or([
328            65.0 / 255.0,
329            65.0 / 255.0,
330            65.0 / 255.0,
331            1.0,
332        ]);
333
334        {
335            let slot = &self.viewport_slots[vp_idx];
336            let dr = slot.dyn_res.as_ref().unwrap();
337            let color_view = &dr.color_view;
338            let depth_view = &dr.depth_view;
339            let camera_bg = &slot.camera_bind_group;
340            let grid_bg = &slot.grid_bind_group;
341
342            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
343                label: Some("ldr_dyn_res_render_pass"),
344                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
345                    view: color_view,
346                    resolve_target: None,
347                    ops: wgpu::Operations {
348                        load: wgpu::LoadOp::Clear(wgpu::Color {
349                            r: bg_color[0] as f64,
350                            g: bg_color[1] as f64,
351                            b: bg_color[2] as f64,
352                            a: bg_color[3] as f64,
353                        }),
354                        store: wgpu::StoreOp::Store,
355                    },
356                    depth_slice: None,
357                })],
358                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
359                    view: depth_view,
360                    depth_ops: Some(wgpu::Operations {
361                        load: wgpu::LoadOp::Clear(1.0),
362                        store: wgpu::StoreOp::Discard,
363                    }),
364                    stencil_ops: None,
365                }),
366                timestamp_writes: None,
367                occlusion_query_set: None,
368            });
369            emit_draw_calls!(
370                &self.resources,
371                &mut render_pass,
372                frame,
373                self.use_instancing,
374                &self.instanced_batches,
375                camera_bg,
376                grid_bg,
377                &self.compute_filter_results,
378                Some(slot)
379            );
380            emit_scivis_draw_calls!(
381                &self.resources,
382                &mut render_pass,
383                &self.point_cloud_gpu_data,
384                &self.glyph_gpu_data,
385                &self.polyline_gpu_data,
386                &self.volume_gpu_data,
387                &self.streamtube_gpu_data,
388                camera_bg
389            );
390            // Implicit surface.
391            if !self.implicit_gpu_data.is_empty() {
392                if let Some(pipeline) = &self.resources.implicit_pipeline {
393                    render_pass.set_pipeline(pipeline);
394                    render_pass.set_bind_group(0, camera_bg, &[]);
395                    for gpu in &self.implicit_gpu_data {
396                        render_pass.set_bind_group(1, &gpu.bind_group, &[]);
397                        render_pass.draw(0..6, 0..1);
398                    }
399                }
400            }
401            // GPU marching cubes indirect draw.
402            if !self.mc_gpu_data.is_empty() {
403                if let Some(pipeline) = &self.resources.mc_surface_pipeline {
404                    render_pass.set_pipeline(pipeline);
405                    render_pass.set_bind_group(0, camera_bg, &[]);
406                    for mc in &self.mc_gpu_data {
407                        let vol = &self.resources.mc_volumes[mc.volume_idx];
408                        render_pass.set_bind_group(1, &mc.render_bg, &[]);
409                        for slab in &vol.slabs {
410                            render_pass.set_vertex_buffer(0, slab.vertex_buf.slice(..));
411                            render_pass.draw_indirect(&slab.indirect_buf, 0);
412                        }
413                    }
414                }
415            }
416            // Sub-object highlight (LDR path).
417            if let Some(sub_hl) = slot.sub_highlight.as_ref() {
418                if let (Some(fill_pl), Some(edge_pl), Some(sprite_pl)) = (
419                    &self.resources.sub_highlight_fill_ldr_pipeline,
420                    &self.resources.sub_highlight_edge_ldr_pipeline,
421                    &self.resources.sub_highlight_sprite_ldr_pipeline,
422                ) {
423                    if sub_hl.fill_vertex_count > 0 {
424                        render_pass.set_pipeline(fill_pl);
425                        render_pass.set_bind_group(0, camera_bg, &[]);
426                        render_pass.set_bind_group(1, &sub_hl.fill_bind_group, &[]);
427                        render_pass.set_vertex_buffer(0, sub_hl.fill_vertex_buf.slice(..));
428                        render_pass.draw(0..sub_hl.fill_vertex_count, 0..1);
429                    }
430                    if sub_hl.edge_segment_count > 0 {
431                        render_pass.set_pipeline(edge_pl);
432                        render_pass.set_bind_group(0, camera_bg, &[]);
433                        render_pass.set_bind_group(1, &sub_hl.edge_bind_group, &[]);
434                        render_pass.set_vertex_buffer(0, sub_hl.edge_vertex_buf.slice(..));
435                        render_pass.draw(0..6, 0..sub_hl.edge_segment_count);
436                    }
437                    if sub_hl.sprite_point_count > 0 {
438                        render_pass.set_pipeline(sprite_pl);
439                        render_pass.set_bind_group(0, camera_bg, &[]);
440                        render_pass.set_bind_group(1, &sub_hl.sprite_bind_group, &[]);
441                        render_pass.set_vertex_buffer(0, sub_hl.sprite_vertex_buf.slice(..));
442                        render_pass.draw(0..6, 0..sub_hl.sprite_point_count);
443                    }
444                }
445            }
446            // Screen-space image overlays.
447            if !self.screen_image_gpu_data.is_empty() {
448                if let Some(pipeline) = &self.resources.screen_image_pipeline {
449                    render_pass.set_pipeline(pipeline);
450                    for gpu in &self.screen_image_gpu_data {
451                        render_pass.set_bind_group(0, &gpu.bind_group, &[]);
452                        render_pass.draw(0..6, 0..1);
453                    }
454                }
455            }
456            // Overlay labels.
457            if let Some(ref ld) = self.label_gpu_data {
458                if let Some(pipeline) = &self.resources.overlay_text_pipeline {
459                    render_pass.set_pipeline(pipeline);
460                    render_pass.set_bind_group(0, &ld.bind_group, &[]);
461                    render_pass.set_vertex_buffer(0, ld.vertex_buf.slice(..));
462                    render_pass.draw(0..ld.vertex_count, 0..1);
463                }
464            }
465            // Scalar bars.
466            if let Some(ref sb) = self.scalar_bar_gpu_data {
467                if let Some(pipeline) = &self.resources.overlay_text_pipeline {
468                    render_pass.set_pipeline(pipeline);
469                    render_pass.set_bind_group(0, &sb.bind_group, &[]);
470                    render_pass.set_vertex_buffer(0, sb.vertex_buf.slice(..));
471                    render_pass.draw(0..sb.vertex_count, 0..1);
472                }
473            }
474            // Rulers.
475            if let Some(ref rd) = self.ruler_gpu_data {
476                if let Some(pipeline) = &self.resources.overlay_text_pipeline {
477                    render_pass.set_pipeline(pipeline);
478                    render_pass.set_bind_group(0, &rd.bind_group, &[]);
479                    render_pass.set_vertex_buffer(0, rd.vertex_buf.slice(..));
480                    render_pass.draw(0..rd.vertex_count, 0..1);
481                }
482            }
483            // Loading bars.
484            if let Some(ref lb) = self.loading_bar_gpu_data {
485                if let Some(pipeline) = &self.resources.overlay_text_pipeline {
486                    render_pass.set_pipeline(pipeline);
487                    render_pass.set_bind_group(0, &lb.bind_group, &[]);
488                    render_pass.set_vertex_buffer(0, lb.vertex_buf.slice(..));
489                    render_pass.draw(0..lb.vertex_count, 0..1);
490                }
491            }
492            // Overlay images (drawn last).
493            if !self.overlay_image_gpu_data.is_empty() {
494                if let Some(pipeline) = &self.resources.screen_image_pipeline {
495                    render_pass.set_pipeline(pipeline);
496                    for gpu in &self.overlay_image_gpu_data {
497                        render_pass.set_bind_group(0, &gpu.bind_group, &[]);
498                        render_pass.draw(0..6, 0..1);
499                    }
500                }
501            }
502        }
503
504        true
505    }
506
507    /// Blit the dyn-res intermediate texture into the provided render pass.
508    ///
509    /// Call from `CallbackTrait::paint` when
510    /// [`prepare_ldr_dyn_res`](Self::prepare_ldr_dyn_res) returned `true` for the same
511    /// frame. Emits a fullscreen upscale quad into `render_pass`.
512    pub fn paint_dyn_res_blit(
513        &self,
514        render_pass: &mut wgpu::RenderPass<'static>,
515        frame: &FrameData,
516    ) {
517        let vp_idx = frame.camera.viewport_index;
518        if let Some(dr) = self.viewport_slots.get(vp_idx).and_then(|s| s.dyn_res.as_ref()) {
519            if let Some(pipeline) = &self.resources.dyn_res_upscale_ds_pipeline {
520                render_pass.set_pipeline(pipeline);
521                render_pass.set_bind_group(0, &dr.upscale_bind_group, &[]);
522                render_pass.draw(0..3, 0..1);
523            }
524        }
525    }
526
527    /// High-level HDR render for a single viewport identified by `id`.
528    ///
529    /// Unlike [`render`](Self::render), this method does **not** call
530    /// [`prepare`](Self::prepare) internally.  The caller must have already called
531    /// [`prepare_scene`](Self::prepare_scene) and
532    /// [`prepare_viewport`](Self::prepare_viewport) for `id` before invoking this.
533    ///
534    /// This is the right entry point for multi-viewport frames:
535    /// 1. Call `prepare_scene` once.
536    /// 2. Call `prepare_viewport` for each viewport.
537    /// 3. Call `render_viewport` for each viewport with its own `output_view`.
538    ///
539    /// Returns a [`wgpu::CommandBuffer`] ready to submit.
540    pub fn render_viewport(
541        &mut self,
542        device: &wgpu::Device,
543        queue: &wgpu::Queue,
544        output_view: &wgpu::TextureView,
545        id: ViewportId,
546        frame: &FrameData,
547    ) -> wgpu::CommandBuffer {
548        self.render_frame_internal(device, queue, output_view, id.0, frame)
549    }
550
551    /// High-level HDR render method. Handles the full post-processing pipeline:
552    /// scene -> HDR texture -> (bloom) -> (SSAO) -> tone map -> output_view.
553    ///
554    /// When `frame.post_process.enabled` is false, falls back to a simple LDR render
555    /// pass targeting `output_view` directly.
556    ///
557    /// Returns a `CommandBuffer` ready to submit.
558    pub fn render(
559        &mut self,
560        device: &wgpu::Device,
561        queue: &wgpu::Queue,
562        output_view: &wgpu::TextureView,
563        frame: &FrameData,
564    ) -> wgpu::CommandBuffer {
565        // Always run prepare() to upload uniforms and run the shadow pass.
566        self.prepare(device, queue, frame);
567        self.render_frame_internal(
568            device,
569            queue,
570            output_view,
571            frame.camera.viewport_index,
572            frame,
573        )
574    }
575
576    /// Render-only path shared by `render()` and `render_viewport()`.
577    ///
578    /// `vp_idx` selects the per-viewport slot to use for camera/HDR state,
579    /// independent of `frame.camera.viewport_index`.
580    fn render_frame_internal(
581        &mut self,
582        device: &wgpu::Device,
583        queue: &wgpu::Queue,
584        output_view: &wgpu::TextureView,
585        vp_idx: usize,
586        frame: &FrameData,
587    ) -> wgpu::CommandBuffer {
588        // Read scene items from the surface submission.
589        let scene_items: &[SceneRenderItem] = match &frame.scene.surfaces {
590            SurfaceSubmission::Flat(items) => items.as_ref(),
591        };
592
593        let bg_color = frame.viewport.background_color.unwrap_or([
594            65.0 / 255.0,
595            65.0 / 255.0,
596            65.0 / 255.0,
597            1.0,
598        ]);
599        let w = frame.camera.viewport_size[0] as u32;
600        let h = frame.camera.viewport_size[1] as u32;
601
602        // Ensure per-viewport HDR targets. Provides a depth buffer for both LDR and HDR paths.
603        let ssaa_factor = frame.effects.post_process.ssaa_factor.max(1);
604        self.ensure_viewport_hdr(device, queue, vp_idx, w.max(1), h.max(1), ssaa_factor);
605
606        // Phase 4 : lazy-initialize GPU timestamp resources on first render call when supported.
607        if self.ts_query_set.is_none()
608            && device.features().contains(wgpu::Features::TIMESTAMP_QUERY)
609        {
610            self.ts_query_set = Some(device.create_query_set(&wgpu::QuerySetDescriptor {
611                label: Some("ts_query_set"),
612                ty: wgpu::QueryType::Timestamp,
613                count: 2,
614            }));
615            self.ts_resolve_buf = Some(device.create_buffer(&wgpu::BufferDescriptor {
616                label: Some("ts_resolve_buf"),
617                size: 16,
618                usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
619                mapped_at_creation: false,
620            }));
621            self.ts_staging_buf = Some(device.create_buffer(&wgpu::BufferDescriptor {
622                label: Some("ts_staging_buf"),
623                size: 16,
624                usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
625                mapped_at_creation: false,
626            }));
627            self.ts_period = queue.get_timestamp_period();
628        }
629
630        if !frame.effects.post_process.enabled {
631            // LDR fallback. When dynamic resolution is active and render_scale < 1.0,
632            // draw into a scaled intermediate texture and upscale-blit to output_view.
633            // Otherwise render directly to output_view.
634            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
635                label: Some("ldr_encoder"),
636            });
637
638            let use_dyn_res = self.current_render_scale < 1.0 - 0.001;
639
640            if use_dyn_res {
641                let sw = ((w as f32 * self.current_render_scale) as u32).max(1);
642                let sh = ((h as f32 * self.current_render_scale) as u32).max(1);
643                self.ensure_dyn_res_target(device, vp_idx, [sw, sh], [w.max(1), h.max(1)]);
644            }
645
646            {
647                let slot = &self.viewport_slots[vp_idx];
648                let slot_hdr = slot.hdr.as_ref().unwrap();
649                let camera_bg = &slot.camera_bind_group;
650                let grid_bg = &slot.grid_bind_group;
651                // Choose render target: dyn_res intermediate or directly output_view.
652                let (scene_color_view, scene_depth_view): (&wgpu::TextureView, &wgpu::TextureView) =
653                    if use_dyn_res {
654                        let dr = slot.dyn_res.as_ref().unwrap();
655                        (&dr.color_view, &dr.depth_view)
656                    } else {
657                        (output_view, &slot_hdr.outline_depth_view)
658                    };
659                let ts_writes = self.ts_query_set.as_ref().map(|qs| {
660                    wgpu::RenderPassTimestampWrites {
661                        query_set: qs,
662                        beginning_of_pass_write_index: Some(0),
663                        end_of_pass_write_index: Some(1),
664                    }
665                });
666                let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
667                    label: Some("ldr_render_pass"),
668                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
669                        view: scene_color_view,
670                        resolve_target: None,
671                        ops: wgpu::Operations {
672                            load: wgpu::LoadOp::Clear(wgpu::Color {
673                                r: bg_color[0] as f64,
674                                g: bg_color[1] as f64,
675                                b: bg_color[2] as f64,
676                                a: bg_color[3] as f64,
677                            }),
678                            store: wgpu::StoreOp::Store,
679                        },
680                        depth_slice: None,
681                    })],
682                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
683                        view: scene_depth_view,
684                        depth_ops: Some(wgpu::Operations {
685                            load: wgpu::LoadOp::Clear(1.0),
686                            store: wgpu::StoreOp::Discard,
687                        }),
688                        stencil_ops: None,
689                    }),
690                    timestamp_writes: ts_writes,
691                    occlusion_query_set: None,
692                });
693                emit_draw_calls!(
694                    &self.resources,
695                    &mut render_pass,
696                    frame,
697                    self.use_instancing,
698                    &self.instanced_batches,
699                    camera_bg,
700                    grid_bg,
701                    &self.compute_filter_results,
702                    Some(slot)
703                );
704                emit_scivis_draw_calls!(
705                    &self.resources,
706                    &mut render_pass,
707                    &self.point_cloud_gpu_data,
708                    &self.glyph_gpu_data,
709                    &self.polyline_gpu_data,
710                    &self.volume_gpu_data,
711                    &self.streamtube_gpu_data,
712                    camera_bg
713                );
714                // Phase 16 : GPU implicit surface.
715                if !self.implicit_gpu_data.is_empty() {
716                    if let Some(pipeline) = &self.resources.implicit_pipeline {
717                        render_pass.set_pipeline(pipeline);
718                        render_pass.set_bind_group(0, camera_bg, &[]);
719                        for gpu in &self.implicit_gpu_data {
720                            render_pass.set_bind_group(1, &gpu.bind_group, &[]);
721                            render_pass.draw(0..6, 0..1);
722                        }
723                    }
724                }
725                // Phase 17 : GPU marching cubes indirect draw.
726                if !self.mc_gpu_data.is_empty() {
727                    if let Some(pipeline) = &self.resources.mc_surface_pipeline {
728                        render_pass.set_pipeline(pipeline);
729                        render_pass.set_bind_group(0, camera_bg, &[]);
730                        for mc in &self.mc_gpu_data {
731                            let vol = &self.resources.mc_volumes[mc.volume_idx];
732                            render_pass.set_bind_group(1, &mc.render_bg, &[]);
733                            for slab in &vol.slabs {
734                                render_pass.set_vertex_buffer(0, slab.vertex_buf.slice(..));
735                                render_pass.draw_indirect(&slab.indirect_buf, 0);
736                            }
737                        }
738                    }
739                }
740                // Phase 10B / Phase 12 : screen-space image overlays.
741                // Regular items drawn with depth_compare: Always (always on top).
742                // Depth-composite items drawn with depth_compare: LessEqual (occluded by
743                // scene geometry whose depth was already written to the depth attachment).
744                if !self.screen_image_gpu_data.is_empty() {
745                    if let Some(overlay_pipeline) = &self.resources.screen_image_pipeline {
746                        let dc_pipeline = self.resources.screen_image_dc_pipeline.as_ref();
747                        for gpu in &self.screen_image_gpu_data {
748                            if let (Some(dc_bg), Some(dc_pipe)) =
749                                (&gpu.depth_bind_group, dc_pipeline)
750                            {
751                                render_pass.set_pipeline(dc_pipe);
752                                render_pass.set_bind_group(0, dc_bg, &[]);
753                            } else {
754                                render_pass.set_pipeline(overlay_pipeline);
755                                render_pass.set_bind_group(0, &gpu.bind_group, &[]);
756                            }
757                            render_pass.draw(0..6, 0..1);
758                        }
759                    }
760                }
761                // Overlay labels (LDR fallback: inside the same render pass).
762                if let Some(ref ld) = self.label_gpu_data {
763                    if let Some(pipeline) = &self.resources.overlay_text_pipeline {
764                        render_pass.set_pipeline(pipeline);
765                        render_pass.set_bind_group(0, &ld.bind_group, &[]);
766                        render_pass.set_vertex_buffer(0, ld.vertex_buf.slice(..));
767                        render_pass.draw(0..ld.vertex_count, 0..1);
768                    }
769                }
770                // Scalar bars (LDR fallback).
771                if let Some(ref sb) = self.scalar_bar_gpu_data {
772                    if let Some(pipeline) = &self.resources.overlay_text_pipeline {
773                        render_pass.set_pipeline(pipeline);
774                        render_pass.set_bind_group(0, &sb.bind_group, &[]);
775                        render_pass.set_vertex_buffer(0, sb.vertex_buf.slice(..));
776                        render_pass.draw(0..sb.vertex_count, 0..1);
777                    }
778                }
779                // Rulers (LDR fallback).
780                if let Some(ref rd) = self.ruler_gpu_data {
781                    if let Some(pipeline) = &self.resources.overlay_text_pipeline {
782                        render_pass.set_pipeline(pipeline);
783                        render_pass.set_bind_group(0, &rd.bind_group, &[]);
784                        render_pass.set_vertex_buffer(0, rd.vertex_buf.slice(..));
785                        render_pass.draw(0..rd.vertex_count, 0..1);
786                    }
787                }
788                // Phase 7 : overlay images (OverlayFrame, LDR fallback, drawn last).
789                if !self.overlay_image_gpu_data.is_empty() {
790                    if let Some(pipeline) = &self.resources.screen_image_pipeline {
791                        render_pass.set_pipeline(pipeline);
792                        for gpu in &self.overlay_image_gpu_data {
793                            render_pass.set_bind_group(0, &gpu.bind_group, &[]);
794                            render_pass.draw(0..6, 0..1);
795                        }
796                    }
797                }
798            }
799
800            // Phase 4 : resolve timestamp queries -> staging buffer.
801            if let (Some(qs), Some(res_buf), Some(stg_buf)) = (
802                self.ts_query_set.as_ref(),
803                self.ts_resolve_buf.as_ref(),
804                self.ts_staging_buf.as_ref(),
805            ) {
806                encoder.resolve_query_set(qs, 0..2, res_buf, 0);
807                encoder.copy_buffer_to_buffer(res_buf, 0, stg_buf, 0, 16);
808                self.ts_needs_readback = true;
809            }
810
811            // Phase 3 : upscale blit from dyn_res intermediate to output_view.
812            if use_dyn_res {
813                let upscale_bg =
814                    &self.viewport_slots[vp_idx].dyn_res.as_ref().unwrap().upscale_bind_group;
815                let mut upscale_pass =
816                    encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
817                        label: Some("dyn_res_upscale_pass"),
818                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
819                            view: output_view,
820                            resolve_target: None,
821                            ops: wgpu::Operations {
822                                load: wgpu::LoadOp::Load,
823                                store: wgpu::StoreOp::Store,
824                            },
825                            depth_slice: None,
826                        })],
827                        depth_stencil_attachment: None,
828                        timestamp_writes: None,
829                        occlusion_query_set: None,
830                    });
831                if let Some(pipeline) = &self.resources.dyn_res_upscale_pipeline {
832                    upscale_pass.set_pipeline(pipeline);
833                    upscale_pass.set_bind_group(0, upscale_bg, &[]);
834                    upscale_pass.draw(0..3, 0..1);
835                }
836            }
837
838            return encoder.finish();
839        }
840
841        // HDR path.
842        let pp = &frame.effects.post_process;
843
844        let hdr_clear_rgb = [
845            bg_color[0].powf(2.2),
846            bg_color[1].powf(2.2),
847            bg_color[2].powf(2.2),
848        ];
849
850        // Upload tone map uniform into the per-viewport buffer.
851        let mode = match pp.tone_mapping {
852            crate::renderer::ToneMapping::Reinhard => 0u32,
853            crate::renderer::ToneMapping::Aces => 1u32,
854            crate::renderer::ToneMapping::KhronosNeutral => 2u32,
855        };
856        let tm_uniform = crate::resources::ToneMapUniform {
857            exposure: pp.exposure,
858            mode,
859            bloom_enabled: if pp.bloom { 1 } else { 0 },
860            ssao_enabled: if pp.ssao { 1 } else { 0 },
861            contact_shadows_enabled: if pp.contact_shadows { 1 } else { 0 },
862            _pad_tm: [0; 3],
863            background_color: bg_color,
864        };
865        {
866            let hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
867            queue.write_buffer(
868                &hdr.tone_map_uniform_buf,
869                0,
870                bytemuck::cast_slice(&[tm_uniform]),
871            );
872
873            // Upload SSAO uniform if needed.
874            if pp.ssao {
875                let proj = frame.camera.render_camera.projection;
876                let inv_proj = proj.inverse();
877                let ssao_uniform = crate::resources::SsaoUniform {
878                    inv_proj: inv_proj.to_cols_array_2d(),
879                    proj: proj.to_cols_array_2d(),
880                    radius: 0.5,
881                    bias: 0.025,
882                    _pad: [0.0; 2],
883                };
884                queue.write_buffer(
885                    &hdr.ssao_uniform_buf,
886                    0,
887                    bytemuck::cast_slice(&[ssao_uniform]),
888                );
889            }
890
891            // Upload contact shadow uniform if needed.
892            if pp.contact_shadows {
893                let proj = frame.camera.render_camera.projection;
894                let inv_proj = proj.inverse();
895                let light_dir_world: glam::Vec3 =
896                    if let Some(l) = frame.effects.lighting.lights.first() {
897                        match l.kind {
898                            LightKind::Directional { direction } => {
899                                glam::Vec3::from(direction).normalize()
900                            }
901                            LightKind::Spot { direction, .. } => {
902                                glam::Vec3::from(direction).normalize()
903                            }
904                            _ => glam::Vec3::new(0.0, -1.0, 0.0),
905                        }
906                    } else {
907                        glam::Vec3::new(0.0, -1.0, 0.0)
908                    };
909                let view = frame.camera.render_camera.view;
910                let light_dir_view = view.transform_vector3(light_dir_world).normalize();
911                let world_up_view = view.transform_vector3(glam::Vec3::Z).normalize();
912                let cs_uniform = crate::resources::ContactShadowUniform {
913                    inv_proj: inv_proj.to_cols_array_2d(),
914                    proj: proj.to_cols_array_2d(),
915                    light_dir_view: [light_dir_view.x, light_dir_view.y, light_dir_view.z, 0.0],
916                    world_up_view: [world_up_view.x, world_up_view.y, world_up_view.z, 0.0],
917                    params: [
918                        pp.contact_shadow_max_distance,
919                        pp.contact_shadow_steps as f32,
920                        pp.contact_shadow_thickness,
921                        0.0,
922                    ],
923                };
924                queue.write_buffer(
925                    &hdr.contact_shadow_uniform_buf,
926                    0,
927                    bytemuck::cast_slice(&[cs_uniform]),
928                );
929            }
930
931            // Upload bloom uniform if needed.
932            if pp.bloom {
933                let bloom_u = crate::resources::BloomUniform {
934                    threshold: pp.bloom_threshold,
935                    intensity: pp.bloom_intensity,
936                    horizontal: 0,
937                    _pad: 0,
938                };
939                queue.write_buffer(&hdr.bloom_uniform_buf, 0, bytemuck::cast_slice(&[bloom_u]));
940            }
941        }
942
943        // Rebuild tone-map bind group with correct bloom/AO texture views.
944        {
945            let hdr = self.viewport_slots[vp_idx].hdr.as_mut().unwrap();
946            self.resources.rebuild_tone_map_bind_group(
947                device,
948                hdr,
949                pp.bloom,
950                pp.ssao,
951                pp.contact_shadows,
952            );
953        }
954
955        // -----------------------------------------------------------------------
956        // Pre-allocate OIT targets if any transparent items exist.
957        // Must happen before camera_bg is borrowed (borrow-checker constraint).
958        // -----------------------------------------------------------------------
959        {
960            let needs_oit = if self.use_instancing && !self.instanced_batches.is_empty() {
961                self.instanced_batches.iter().any(|b| b.is_transparent)
962            } else {
963                scene_items
964                    .iter()
965                    .any(|i| i.visible && i.material.opacity < 1.0)
966            };
967            if needs_oit {
968                let hdr = self.viewport_slots[vp_idx].hdr.as_mut().unwrap();
969                self.resources
970                    .ensure_viewport_oit(device, hdr, w.max(1), h.max(1));
971            }
972        }
973
974        // -----------------------------------------------------------------------
975        // Build the command encoder.
976        // -----------------------------------------------------------------------
977        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
978            label: Some("hdr_encoder"),
979        });
980
981        // Per-viewport camera bind group and HDR state for the HDR path.
982        let slot = &self.viewport_slots[vp_idx];
983        let camera_bg = &slot.camera_bind_group;
984        let slot_hdr = slot.hdr.as_ref().unwrap();
985
986        // -----------------------------------------------------------------------
987        // HDR scene pass: render geometry into the HDR texture.
988        // -----------------------------------------------------------------------
989        {
990            // Use SSAA target if enabled, otherwise render directly to hdr_texture.
991            let use_ssaa = ssaa_factor > 1
992                && slot_hdr.ssaa_color_view.is_some()
993                && slot_hdr.ssaa_depth_view.is_some();
994            let scene_color_view = if use_ssaa {
995                slot_hdr.ssaa_color_view.as_ref().unwrap()
996            } else {
997                &slot_hdr.hdr_view
998            };
999            let scene_depth_view = if use_ssaa {
1000                slot_hdr.ssaa_depth_view.as_ref().unwrap()
1001            } else {
1002                &slot_hdr.hdr_depth_view
1003            };
1004
1005            let clear_wgpu = wgpu::Color {
1006                r: hdr_clear_rgb[0] as f64,
1007                g: hdr_clear_rgb[1] as f64,
1008                b: hdr_clear_rgb[2] as f64,
1009                a: bg_color[3] as f64,
1010            };
1011
1012            let hdr_ts_writes = self.ts_query_set.as_ref().map(|qs| {
1013                wgpu::RenderPassTimestampWrites {
1014                    query_set: qs,
1015                    beginning_of_pass_write_index: Some(0),
1016                    end_of_pass_write_index: Some(1),
1017                }
1018            });
1019            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1020                label: Some("hdr_scene_pass"),
1021                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1022                    view: scene_color_view,
1023                    resolve_target: None,
1024                    ops: wgpu::Operations {
1025                        load: wgpu::LoadOp::Clear(clear_wgpu),
1026                        store: wgpu::StoreOp::Store,
1027                    },
1028                    depth_slice: None,
1029                })],
1030                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1031                    view: scene_depth_view,
1032                    depth_ops: Some(wgpu::Operations {
1033                        load: wgpu::LoadOp::Clear(1.0),
1034                        store: wgpu::StoreOp::Store,
1035                    }),
1036                    stencil_ops: Some(wgpu::Operations {
1037                        load: wgpu::LoadOp::Clear(0),
1038                        store: wgpu::StoreOp::Store,
1039                    }),
1040                }),
1041                timestamp_writes: hdr_ts_writes,
1042                occlusion_query_set: None,
1043            });
1044
1045            let resources = &self.resources;
1046            render_pass.set_bind_group(0, camera_bg, &[]);
1047
1048            // Check skybox eligibility early; drawn after all opaques below.
1049            let show_skybox = frame
1050                .effects
1051                .environment
1052                .as_ref()
1053                .is_some_and(|e| e.show_skybox)
1054                && resources.ibl_skybox_view.is_some();
1055
1056            let use_instancing = self.use_instancing;
1057            let batches = &self.instanced_batches;
1058
1059            if !scene_items.is_empty() {
1060                if use_instancing && !batches.is_empty() {
1061                    let excluded_items: Vec<&SceneRenderItem> = scene_items
1062                        .iter()
1063                        .filter(|item| {
1064                            item.visible
1065                                && (item.active_attribute.is_some()
1066                                    || item.material.is_two_sided()
1067                                    || item.material.matcap_id.is_some())
1068                                && resources
1069                                    .mesh_store
1070                                    .get(item.mesh_id)
1071                                    .is_some()
1072                        })
1073                        .collect();
1074
1075                    // Separate opaque and transparent batches.
1076                    // Carry the global batch index (position in `batches`) alongside each batch
1077                    // so draw_indexed_indirect can compute the correct buffer offset.
1078                    let mut opaque_batches: Vec<(usize, &InstancedBatch)> = Vec::new();
1079                    let mut transparent_batches: Vec<(usize, &InstancedBatch)> = Vec::new();
1080                    for (batch_global_idx, batch) in batches.iter().enumerate() {
1081                        if batch.is_transparent {
1082                            transparent_batches.push((batch_global_idx, batch));
1083                        } else {
1084                            opaque_batches.push((batch_global_idx, batch));
1085                        }
1086                    }
1087
1088                    if !opaque_batches.is_empty() && !frame.viewport.wireframe_mode {
1089                        let use_indirect = self.gpu_culling_enabled
1090                            && resources.hdr_solid_instanced_cull_pipeline.is_some()
1091                            && resources.indirect_args_buf.is_some();
1092
1093                        if use_indirect {
1094                            if let (
1095                                Some(pipeline),
1096                                Some(indirect_buf),
1097                            ) = (
1098                                &resources.hdr_solid_instanced_cull_pipeline,
1099                                &resources.indirect_args_buf,
1100                            ) {
1101                                render_pass.set_pipeline(pipeline);
1102                                for (batch_global_idx, batch) in &opaque_batches {
1103                                    let Some(mesh) = resources.mesh_store.get(batch.mesh_id)
1104                                    else {
1105                                        continue;
1106                                    };
1107                                    let mat_key = (
1108                                        batch.texture_id.unwrap_or(u64::MAX),
1109                                        batch.normal_map_id.unwrap_or(u64::MAX),
1110                                        batch.ao_map_id.unwrap_or(u64::MAX),
1111                                    );
1112                                    let Some(inst_tex_bg) =
1113                                        resources.instance_cull_bind_groups.get(&mat_key)
1114                                    else {
1115                                        continue;
1116                                    };
1117                                    render_pass.set_bind_group(1, inst_tex_bg, &[]);
1118                                    render_pass.set_vertex_buffer(
1119                                        0,
1120                                        mesh.vertex_buffer.slice(..),
1121                                    );
1122                                    render_pass.set_index_buffer(
1123                                        mesh.index_buffer.slice(..),
1124                                        wgpu::IndexFormat::Uint32,
1125                                    );
1126                                    // Each DrawIndexedIndirect entry is 20 bytes; index by global
1127                                    // batch position so the offset matches write_indirect_args output.
1128                                    render_pass.draw_indexed_indirect(
1129                                        indirect_buf,
1130                                        *batch_global_idx as u64 * 20,
1131                                    );
1132                                }
1133                            }
1134                        } else if let Some(ref pipeline) = resources.hdr_solid_instanced_pipeline {
1135                            render_pass.set_pipeline(pipeline);
1136                            for (_, batch) in &opaque_batches {
1137                                let Some(mesh) = resources
1138                                    .mesh_store
1139                                    .get(batch.mesh_id)
1140                                else {
1141                                    continue;
1142                                };
1143                                let mat_key = (
1144                                    batch.texture_id.unwrap_or(u64::MAX),
1145                                    batch.normal_map_id.unwrap_or(u64::MAX),
1146                                    batch.ao_map_id.unwrap_or(u64::MAX),
1147                                );
1148                                let Some(inst_tex_bg) =
1149                                    resources.instance_bind_groups.get(&mat_key)
1150                                else {
1151                                    continue;
1152                                };
1153                                render_pass.set_bind_group(1, inst_tex_bg, &[]);
1154                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1155                                render_pass.set_index_buffer(
1156                                    mesh.index_buffer.slice(..),
1157                                    wgpu::IndexFormat::Uint32,
1158                                );
1159                                render_pass.draw_indexed(
1160                                    0..mesh.index_count,
1161                                    0,
1162                                    batch.instance_offset
1163                                        ..batch.instance_offset + batch.instance_count,
1164                                );
1165                            }
1166                        }
1167                    }
1168
1169                    // NOTE: transparent_batches are now rendered in the OIT pass below,
1170                    // not in the HDR scene pass. This block intentionally left empty.
1171                    let _ = &transparent_batches; // suppress unused warning
1172
1173                    if frame.viewport.wireframe_mode {
1174                        if let Some(ref hdr_wf) = resources.hdr_wireframe_pipeline {
1175                            render_pass.set_pipeline(hdr_wf);
1176                            for item in scene_items {
1177                                if !item.visible {
1178                                    continue;
1179                                }
1180                                let Some(mesh) = resources
1181                                    .mesh_store
1182                                    .get(item.mesh_id)
1183                                else {
1184                                    continue;
1185                                };
1186                                render_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
1187                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1188                                render_pass.set_index_buffer(
1189                                    mesh.edge_index_buffer.slice(..),
1190                                    wgpu::IndexFormat::Uint32,
1191                                );
1192                                render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
1193                            }
1194                        }
1195                    } else if let (Some(hdr_solid), Some(hdr_solid_two_sided)) = (
1196                        &resources.hdr_solid_pipeline,
1197                        &resources.hdr_solid_two_sided_pipeline,
1198                    ) {
1199                        for item in excluded_items
1200                            .into_iter()
1201                            .filter(|item| item.material.opacity >= 1.0)
1202                        {
1203                            let Some(mesh) = resources
1204                                .mesh_store
1205                                .get(item.mesh_id)
1206                            else {
1207                                continue;
1208                            };
1209                            let pipeline = if item.material.is_two_sided() {
1210                                hdr_solid_two_sided
1211                            } else {
1212                                hdr_solid
1213                            };
1214                            render_pass.set_pipeline(pipeline);
1215                            render_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
1216                            render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1217                            render_pass.set_index_buffer(
1218                                mesh.index_buffer.slice(..),
1219                                wgpu::IndexFormat::Uint32,
1220                            );
1221                            render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1222                        }
1223                    }
1224                } else {
1225                    // Per-object path.
1226                    let eye = glam::Vec3::from(frame.camera.render_camera.eye_position);
1227                    let dist_from_eye = |item: &&SceneRenderItem| -> f32 {
1228                        let pos =
1229                            glam::Vec3::new(item.model[3][0], item.model[3][1], item.model[3][2]);
1230                        (pos - eye).length()
1231                    };
1232
1233                    let mut opaque: Vec<&SceneRenderItem> = Vec::new();
1234                    let mut transparent: Vec<&SceneRenderItem> = Vec::new();
1235                    for item in scene_items {
1236                        if !item.visible
1237                            || resources
1238                                .mesh_store
1239                                .get(item.mesh_id)
1240                                .is_none()
1241                        {
1242                            continue;
1243                        }
1244                        if item.material.opacity < 1.0 {
1245                            transparent.push(item);
1246                        } else {
1247                            opaque.push(item);
1248                        }
1249                    }
1250                    opaque.sort_by(|a, b| {
1251                        dist_from_eye(a)
1252                            .partial_cmp(&dist_from_eye(b))
1253                            .unwrap_or(std::cmp::Ordering::Equal)
1254                    });
1255                    transparent.sort_by(|a, b| {
1256                        dist_from_eye(b)
1257                            .partial_cmp(&dist_from_eye(a))
1258                            .unwrap_or(std::cmp::Ordering::Equal)
1259                    });
1260
1261                    let draw_item_hdr =
1262                        |render_pass: &mut wgpu::RenderPass<'_>,
1263                         item: &SceneRenderItem,
1264                         solid_pl: &wgpu::RenderPipeline,
1265                         trans_pl: &wgpu::RenderPipeline,
1266                         wf_pl: &wgpu::RenderPipeline| {
1267                            let mesh = resources
1268                                .mesh_store
1269                                .get(item.mesh_id)
1270                                .unwrap();
1271                            // mesh.object_bind_group (group 1) already carries the object uniform
1272                            // and the correct texture views.
1273                            render_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
1274                            let is_face_attr = item.active_attribute.as_ref().map_or(false, |a| {
1275                                matches!(
1276                                    a.kind,
1277                                    crate::resources::AttributeKind::Face
1278                                        | crate::resources::AttributeKind::FaceColor
1279                                        | crate::resources::AttributeKind::Halfedge
1280                                        | crate::resources::AttributeKind::Corner
1281                                )
1282                            });
1283                            if frame.viewport.wireframe_mode {
1284                                render_pass.set_pipeline(wf_pl);
1285                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1286                                render_pass.set_index_buffer(
1287                                    mesh.edge_index_buffer.slice(..),
1288                                    wgpu::IndexFormat::Uint32,
1289                                );
1290                                render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
1291                            } else if is_face_attr {
1292                                if let Some(ref fvb) = mesh.face_vertex_buffer {
1293                                    let pl = if item.material.opacity < 1.0 {
1294                                        trans_pl
1295                                    } else {
1296                                        solid_pl
1297                                    };
1298                                    render_pass.set_pipeline(pl);
1299                                    render_pass.set_vertex_buffer(0, fvb.slice(..));
1300                                    render_pass.draw(0..mesh.index_count, 0..1);
1301                                }
1302                            } else if item.material.opacity < 1.0 {
1303                                render_pass.set_pipeline(trans_pl);
1304                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1305                                render_pass.set_index_buffer(
1306                                    mesh.index_buffer.slice(..),
1307                                    wgpu::IndexFormat::Uint32,
1308                                );
1309                                render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1310                            } else {
1311                                render_pass.set_pipeline(solid_pl);
1312                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1313                                render_pass.set_index_buffer(
1314                                    mesh.index_buffer.slice(..),
1315                                    wgpu::IndexFormat::Uint32,
1316                                );
1317                                render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1318                            }
1319                        };
1320
1321                    // NOTE: only opaque items are drawn here. Transparent items are
1322                    // routed to the OIT pass below.
1323                    let _ = &transparent; // suppress unused warning
1324                    if let (
1325                        Some(hdr_solid),
1326                        Some(hdr_solid_two_sided),
1327                        Some(hdr_trans),
1328                        Some(hdr_wf),
1329                    ) = (
1330                        &resources.hdr_solid_pipeline,
1331                        &resources.hdr_solid_two_sided_pipeline,
1332                        &resources.hdr_transparent_pipeline,
1333                        &resources.hdr_wireframe_pipeline,
1334                    ) {
1335                        for item in &opaque {
1336                            let solid_pl = if item.material.is_two_sided() {
1337                                hdr_solid_two_sided
1338                            } else {
1339                                hdr_solid
1340                            };
1341                            draw_item_hdr(&mut render_pass, item, solid_pl, hdr_trans, hdr_wf);
1342                        }
1343                    }
1344                }
1345            }
1346
1347            // Cap fill pass (HDR path : section view cross-section fill).
1348            if !slot.cap_buffers.is_empty() {
1349                if let Some(ref hdr_overlay) = resources.hdr_overlay_pipeline {
1350                    render_pass.set_pipeline(hdr_overlay);
1351                    render_pass.set_bind_group(0, camera_bg, &[]);
1352                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.cap_buffers {
1353                        render_pass.set_bind_group(1, bg, &[]);
1354                        render_pass.set_vertex_buffer(0, vbuf.slice(..));
1355                        render_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
1356                        render_pass.draw_indexed(0..*idx_count, 0, 0..1);
1357                    }
1358                }
1359            }
1360
1361            // SciVis Phase B+D+M8+M: point cloud, glyph, polyline, volume, streamtube (HDR path).
1362            emit_scivis_draw_calls!(
1363                &self.resources,
1364                &mut render_pass,
1365                &self.point_cloud_gpu_data,
1366                &self.glyph_gpu_data,
1367                &self.polyline_gpu_data,
1368                &self.volume_gpu_data,
1369                &self.streamtube_gpu_data,
1370                camera_bg
1371            );
1372
1373            // Phase 16 : GPU implicit surface (HDR path, before skybox).
1374            if !self.implicit_gpu_data.is_empty() {
1375                if let Some(pipeline) = &self.resources.implicit_pipeline {
1376                    render_pass.set_pipeline(pipeline);
1377                    render_pass.set_bind_group(0, camera_bg, &[]);
1378                    for gpu in &self.implicit_gpu_data {
1379                        render_pass.set_bind_group(1, &gpu.bind_group, &[]);
1380                        render_pass.draw(0..6, 0..1);
1381                    }
1382                }
1383            }
1384            // Phase 17 : GPU marching cubes indirect draw (HDR path).
1385            if !self.mc_gpu_data.is_empty() {
1386                if let Some(pipeline) = &self.resources.mc_surface_pipeline {
1387                    render_pass.set_pipeline(pipeline);
1388                    render_pass.set_bind_group(0, camera_bg, &[]);
1389                    for mc in &self.mc_gpu_data {
1390                        let vol = &self.resources.mc_volumes[mc.volume_idx];
1391                        render_pass.set_bind_group(1, &mc.render_bg, &[]);
1392                        for slab in &vol.slabs {
1393                            render_pass.set_vertex_buffer(0, slab.vertex_buf.slice(..));
1394                            render_pass.draw_indirect(&slab.indirect_buf, 0);
1395                        }
1396                    }
1397                }
1398            }
1399
1400            // Draw skybox last among opaques : only uncovered sky pixels pass depth == 1.0.
1401            if show_skybox {
1402                render_pass.set_bind_group(0, camera_bg, &[]);
1403                render_pass.set_pipeline(&resources.skybox_pipeline);
1404                render_pass.draw(0..3, 0..1);
1405            }
1406        }
1407
1408        // -----------------------------------------------------------------------
1409        // SSAA resolve pass: downsample supersampled scene -> hdr_texture.
1410        // Only runs when ssaa_factor > 1 and the resolve pipeline is available.
1411        // -----------------------------------------------------------------------
1412        if ssaa_factor > 1 {
1413            let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
1414            if let (Some(pipeline), Some(bg)) = (
1415                &self.resources.ssaa_resolve_pipeline,
1416                &slot_hdr.ssaa_resolve_bind_group,
1417            ) {
1418                let mut resolve_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1419                    label: Some("ssaa_resolve_pass"),
1420                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1421                        view: &slot_hdr.hdr_view,
1422                        resolve_target: None,
1423                        ops: wgpu::Operations {
1424                            load: wgpu::LoadOp::Load,
1425                            store: wgpu::StoreOp::Store,
1426                        },
1427                        depth_slice: None,
1428                    })],
1429                    depth_stencil_attachment: None,
1430                    timestamp_writes: None,
1431                    occlusion_query_set: None,
1432                });
1433                resolve_pass.set_pipeline(pipeline);
1434                resolve_pass.set_bind_group(0, bg, &[]);
1435                resolve_pass.draw(0..3, 0..1);
1436            }
1437        }
1438
1439        // -----------------------------------------------------------------------
1440        // Sub-object highlight pass: face fill, edge lines, vertex sprites.
1441        // Runs after opaque geometry (depth buffer is ready) and before OIT so
1442        // highlights are not occluded by opaque surfaces.
1443        // -----------------------------------------------------------------------
1444        if let Some(sub_hl) = self.viewport_slots[vp_idx].sub_highlight.as_ref() {
1445            let resources = &self.resources;
1446            if let (Some(fill_pl), Some(edge_pl), Some(sprite_pl)) = (
1447                &resources.sub_highlight_fill_pipeline,
1448                &resources.sub_highlight_edge_pipeline,
1449                &resources.sub_highlight_sprite_pipeline,
1450            ) {
1451                let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
1452                let camera_bg = &self.viewport_slots[vp_idx].camera_bind_group;
1453                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1454                    label: Some("sub_highlight_pass"),
1455                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1456                        view: &slot_hdr.hdr_view,
1457                        resolve_target: None,
1458                        ops: wgpu::Operations {
1459                            load: wgpu::LoadOp::Load,
1460                            store: wgpu::StoreOp::Store,
1461                        },
1462                        depth_slice: None,
1463                    })],
1464                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1465                        view: &slot_hdr.hdr_depth_view,
1466                        depth_ops: Some(wgpu::Operations {
1467                            load: wgpu::LoadOp::Load,
1468                            store: wgpu::StoreOp::Discard,
1469                        }),
1470                        stencil_ops: None,
1471                    }),
1472                    timestamp_writes: None,
1473                    occlusion_query_set: None,
1474                });
1475
1476                if sub_hl.fill_vertex_count > 0 {
1477                    pass.set_pipeline(fill_pl);
1478                    pass.set_bind_group(0, camera_bg, &[]);
1479                    pass.set_bind_group(1, &sub_hl.fill_bind_group, &[]);
1480                    pass.set_vertex_buffer(0, sub_hl.fill_vertex_buf.slice(..));
1481                    pass.draw(0..sub_hl.fill_vertex_count, 0..1);
1482                }
1483                if sub_hl.edge_segment_count > 0 {
1484                    pass.set_pipeline(edge_pl);
1485                    pass.set_bind_group(0, camera_bg, &[]);
1486                    pass.set_bind_group(1, &sub_hl.edge_bind_group, &[]);
1487                    pass.set_vertex_buffer(0, sub_hl.edge_vertex_buf.slice(..));
1488                    pass.draw(0..6, 0..sub_hl.edge_segment_count);
1489                }
1490                if sub_hl.sprite_point_count > 0 {
1491                    pass.set_pipeline(sprite_pl);
1492                    pass.set_bind_group(0, camera_bg, &[]);
1493                    pass.set_bind_group(1, &sub_hl.sprite_bind_group, &[]);
1494                    pass.set_vertex_buffer(0, sub_hl.sprite_vertex_buf.slice(..));
1495                    pass.draw(0..6, 0..sub_hl.sprite_point_count);
1496                }
1497            }
1498        }
1499
1500        // -----------------------------------------------------------------------
1501        // OIT pass: render transparent items into accum + reveal textures.
1502        // Completely skipped when no transparent items exist (zero overhead).
1503        // -----------------------------------------------------------------------
1504        let has_transparent = if self.use_instancing && !self.instanced_batches.is_empty() {
1505            self.instanced_batches.iter().any(|b| b.is_transparent)
1506        } else {
1507            scene_items
1508                .iter()
1509                .any(|i| i.visible && i.material.opacity < 1.0)
1510        };
1511
1512        if has_transparent {
1513            // OIT targets already allocated in the pre-pass above.
1514            if let (Some(accum_view), Some(reveal_view)) = (
1515                slot_hdr.oit_accum_view.as_ref(),
1516                slot_hdr.oit_reveal_view.as_ref(),
1517            ) {
1518                let hdr_depth_view = &slot_hdr.hdr_depth_view;
1519                // Clear accum to (0,0,0,0), reveal to 1.0 (no contribution yet).
1520                let mut oit_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1521                    label: Some("oit_pass"),
1522                    color_attachments: &[
1523                        Some(wgpu::RenderPassColorAttachment {
1524                            view: accum_view,
1525                            resolve_target: None,
1526                            ops: wgpu::Operations {
1527                                load: wgpu::LoadOp::Clear(wgpu::Color {
1528                                    r: 0.0,
1529                                    g: 0.0,
1530                                    b: 0.0,
1531                                    a: 0.0,
1532                                }),
1533                                store: wgpu::StoreOp::Store,
1534                            },
1535                            depth_slice: None,
1536                        }),
1537                        Some(wgpu::RenderPassColorAttachment {
1538                            view: reveal_view,
1539                            resolve_target: None,
1540                            ops: wgpu::Operations {
1541                                load: wgpu::LoadOp::Clear(wgpu::Color {
1542                                    r: 1.0,
1543                                    g: 1.0,
1544                                    b: 1.0,
1545                                    a: 1.0,
1546                                }),
1547                                store: wgpu::StoreOp::Store,
1548                            },
1549                            depth_slice: None,
1550                        }),
1551                    ],
1552                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1553                        view: hdr_depth_view,
1554                        depth_ops: Some(wgpu::Operations {
1555                            load: wgpu::LoadOp::Load, // reuse opaque depth
1556                            store: wgpu::StoreOp::Store,
1557                        }),
1558                        stencil_ops: None,
1559                    }),
1560                    timestamp_writes: None,
1561                    occlusion_query_set: None,
1562                });
1563
1564                oit_pass.set_bind_group(0, camera_bg, &[]);
1565
1566                if self.use_instancing && !self.instanced_batches.is_empty() {
1567                    let use_indirect_oit = self.gpu_culling_enabled
1568                        && self.resources.oit_instanced_cull_pipeline.is_some()
1569                        && self.resources.indirect_args_buf.is_some();
1570
1571                    if use_indirect_oit {
1572                        if let (
1573                            Some(pipeline),
1574                            Some(indirect_buf),
1575                        ) = (
1576                            &self.resources.oit_instanced_cull_pipeline,
1577                            &self.resources.indirect_args_buf,
1578                        ) {
1579                            oit_pass.set_pipeline(pipeline);
1580                            for (batch_global_idx, batch) in
1581                                self.instanced_batches.iter().enumerate()
1582                            {
1583                                if !batch.is_transparent {
1584                                    continue;
1585                                }
1586                                let Some(mesh) =
1587                                    self.resources.mesh_store.get(batch.mesh_id)
1588                                else {
1589                                    continue;
1590                                };
1591                                let mat_key = (
1592                                    batch.texture_id.unwrap_or(u64::MAX),
1593                                    batch.normal_map_id.unwrap_or(u64::MAX),
1594                                    batch.ao_map_id.unwrap_or(u64::MAX),
1595                                );
1596                                let Some(inst_tex_bg) =
1597                                    self.resources.instance_cull_bind_groups.get(&mat_key)
1598                                else {
1599                                    continue;
1600                                };
1601                                oit_pass.set_bind_group(1, inst_tex_bg, &[]);
1602                                oit_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1603                                oit_pass.set_index_buffer(
1604                                    mesh.index_buffer.slice(..),
1605                                    wgpu::IndexFormat::Uint32,
1606                                );
1607                                oit_pass.draw_indexed_indirect(
1608                                    indirect_buf,
1609                                    batch_global_idx as u64 * 20,
1610                                );
1611                            }
1612                        }
1613                    } else if let Some(ref pipeline) = self.resources.oit_instanced_pipeline {
1614                        oit_pass.set_pipeline(pipeline);
1615                        for batch in &self.instanced_batches {
1616                            if !batch.is_transparent {
1617                                continue;
1618                            }
1619                            let Some(mesh) = self
1620                                .resources
1621                                .mesh_store
1622                                .get(batch.mesh_id)
1623                            else {
1624                                continue;
1625                            };
1626                            let mat_key = (
1627                                batch.texture_id.unwrap_or(u64::MAX),
1628                                batch.normal_map_id.unwrap_or(u64::MAX),
1629                                batch.ao_map_id.unwrap_or(u64::MAX),
1630                            );
1631                            let Some(inst_tex_bg) =
1632                                self.resources.instance_bind_groups.get(&mat_key)
1633                            else {
1634                                continue;
1635                            };
1636                            oit_pass.set_bind_group(1, inst_tex_bg, &[]);
1637                            oit_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1638                            oit_pass.set_index_buffer(
1639                                mesh.index_buffer.slice(..),
1640                                wgpu::IndexFormat::Uint32,
1641                            );
1642                            oit_pass.draw_indexed(
1643                                0..mesh.index_count,
1644                                0,
1645                                batch.instance_offset..batch.instance_offset + batch.instance_count,
1646                            );
1647                        }
1648                    }
1649                } else if let Some(ref pipeline) = self.resources.oit_pipeline {
1650                    oit_pass.set_pipeline(pipeline);
1651                    for item in scene_items {
1652                        if !item.visible || item.material.opacity >= 1.0 {
1653                            continue;
1654                        }
1655                        let Some(mesh) = self
1656                            .resources
1657                            .mesh_store
1658                            .get(item.mesh_id)
1659                        else {
1660                            continue;
1661                        };
1662                        oit_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
1663                        oit_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1664                        oit_pass.set_index_buffer(
1665                            mesh.index_buffer.slice(..),
1666                            wgpu::IndexFormat::Uint32,
1667                        );
1668                        oit_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1669                    }
1670                }
1671            }
1672        }
1673
1674        // -----------------------------------------------------------------------
1675        // OIT composite pass: blend accum/reveal into HDR buffer.
1676        // Only executes when transparent items were present.
1677        // -----------------------------------------------------------------------
1678        if has_transparent {
1679            if let (Some(pipeline), Some(bg)) = (
1680                self.resources.oit_composite_pipeline.as_ref(),
1681                slot_hdr.oit_composite_bind_group.as_ref(),
1682            ) {
1683                let hdr_view = &slot_hdr.hdr_view;
1684                let mut composite_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1685                    label: Some("oit_composite_pass"),
1686                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1687                        view: hdr_view,
1688                        resolve_target: None,
1689                        ops: wgpu::Operations {
1690                            load: wgpu::LoadOp::Load,
1691                            store: wgpu::StoreOp::Store,
1692                        },
1693                        depth_slice: None,
1694                    })],
1695                    depth_stencil_attachment: None,
1696                    timestamp_writes: None,
1697                    occlusion_query_set: None,
1698                });
1699                composite_pass.set_pipeline(pipeline);
1700                composite_pass.set_bind_group(0, bg, &[]);
1701                composite_pass.draw(0..3, 0..1);
1702            }
1703        }
1704
1705        // -----------------------------------------------------------------------
1706        // Outline composite pass (HDR path): blit offscreen outline onto hdr_view.
1707        // Runs after the HDR scene pass (which has depth+stencil) in a separate
1708        // pass with no depth attachment, so the composite pipeline is compatible.
1709        // -----------------------------------------------------------------------
1710        if !slot.outline_object_buffers.is_empty() {
1711            // Prefer the HDR-format pipeline; fall back to LDR single-sample.
1712            let hdr_pipeline = self
1713                .resources
1714                .outline_composite_pipeline_hdr
1715                .as_ref()
1716                .or(self.resources.outline_composite_pipeline_single.as_ref());
1717            if let Some(pipeline) = hdr_pipeline {
1718                let bg = &slot_hdr.outline_composite_bind_group;
1719                let hdr_view = &slot_hdr.hdr_view;
1720                let hdr_depth_view = &slot_hdr.hdr_depth_view;
1721                let mut outline_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1722                    label: Some("hdr_outline_composite_pass"),
1723                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1724                        view: hdr_view,
1725                        resolve_target: None,
1726                        ops: wgpu::Operations {
1727                            load: wgpu::LoadOp::Load,
1728                            store: wgpu::StoreOp::Store,
1729                        },
1730                        depth_slice: None,
1731                    })],
1732                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1733                        view: hdr_depth_view,
1734                        depth_ops: Some(wgpu::Operations {
1735                            load: wgpu::LoadOp::Load,
1736                            store: wgpu::StoreOp::Store,
1737                        }),
1738                        stencil_ops: None,
1739                    }),
1740                    timestamp_writes: None,
1741                    occlusion_query_set: None,
1742                });
1743                outline_pass.set_pipeline(pipeline);
1744                outline_pass.set_bind_group(0, bg, &[]);
1745                outline_pass.draw(0..3, 0..1);
1746            }
1747        }
1748
1749        // Phase 5 : effect throttling. Flag was computed in prepare() so that
1750        // FrameStats reports exactly what fired rather than an approximation.
1751        let throttle_effects = self.degradation_effects_throttled;
1752
1753        // -----------------------------------------------------------------------
1754        // SSAO pass.
1755        // -----------------------------------------------------------------------
1756        if pp.ssao && !throttle_effects {
1757            if let Some(ssao_pipeline) = &self.resources.ssao_pipeline {
1758                {
1759                    let mut ssao_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1760                        label: Some("ssao_pass"),
1761                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1762                            view: &slot_hdr.ssao_view,
1763                            resolve_target: None,
1764                            ops: wgpu::Operations {
1765                                load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
1766                                store: wgpu::StoreOp::Store,
1767                            },
1768                            depth_slice: None,
1769                        })],
1770                        depth_stencil_attachment: None,
1771                        timestamp_writes: None,
1772                        occlusion_query_set: None,
1773                    });
1774                    ssao_pass.set_pipeline(ssao_pipeline);
1775                    ssao_pass.set_bind_group(0, &slot_hdr.ssao_bg, &[]);
1776                    ssao_pass.draw(0..3, 0..1);
1777                }
1778
1779                // SSAO blur pass.
1780                if let Some(ssao_blur_pipeline) = &self.resources.ssao_blur_pipeline {
1781                    let mut ssao_blur_pass =
1782                        encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1783                            label: Some("ssao_blur_pass"),
1784                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1785                                view: &slot_hdr.ssao_blur_view,
1786                                resolve_target: None,
1787                                ops: wgpu::Operations {
1788                                    load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
1789                                    store: wgpu::StoreOp::Store,
1790                                },
1791                                depth_slice: None,
1792                            })],
1793                            depth_stencil_attachment: None,
1794                            timestamp_writes: None,
1795                            occlusion_query_set: None,
1796                        });
1797                    ssao_blur_pass.set_pipeline(ssao_blur_pipeline);
1798                    ssao_blur_pass.set_bind_group(0, &slot_hdr.ssao_blur_bg, &[]);
1799                    ssao_blur_pass.draw(0..3, 0..1);
1800                }
1801            }
1802        }
1803
1804        // -----------------------------------------------------------------------
1805        // Contact shadow pass.
1806        // -----------------------------------------------------------------------
1807        if pp.contact_shadows && !throttle_effects {
1808            if let Some(cs_pipeline) = &self.resources.contact_shadow_pipeline {
1809                let mut cs_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1810                    label: Some("contact_shadow_pass"),
1811                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1812                        view: &slot_hdr.contact_shadow_view,
1813                        resolve_target: None,
1814                        depth_slice: None,
1815                        ops: wgpu::Operations {
1816                            load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
1817                            store: wgpu::StoreOp::Store,
1818                        },
1819                    })],
1820                    depth_stencil_attachment: None,
1821                    timestamp_writes: None,
1822                    occlusion_query_set: None,
1823                });
1824                cs_pass.set_pipeline(cs_pipeline);
1825                cs_pass.set_bind_group(0, &slot_hdr.contact_shadow_bg, &[]);
1826                cs_pass.draw(0..3, 0..1);
1827            }
1828        }
1829
1830        // -----------------------------------------------------------------------
1831        // Bloom passes.
1832        // -----------------------------------------------------------------------
1833        if pp.bloom && !throttle_effects {
1834            // Threshold pass: extract bright pixels into bloom_threshold_texture.
1835            if let Some(bloom_threshold_pipeline) = &self.resources.bloom_threshold_pipeline {
1836                {
1837                    let mut threshold_pass =
1838                        encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1839                            label: Some("bloom_threshold_pass"),
1840                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1841                                view: &slot_hdr.bloom_threshold_view,
1842                                resolve_target: None,
1843                                ops: wgpu::Operations {
1844                                    load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1845                                    store: wgpu::StoreOp::Store,
1846                                },
1847                                depth_slice: None,
1848                            })],
1849                            depth_stencil_attachment: None,
1850                            timestamp_writes: None,
1851                            occlusion_query_set: None,
1852                        });
1853                    threshold_pass.set_pipeline(bloom_threshold_pipeline);
1854                    threshold_pass.set_bind_group(0, &slot_hdr.bloom_threshold_bg, &[]);
1855                    threshold_pass.draw(0..3, 0..1);
1856                }
1857
1858                // 4 ping-pong H+V blur passes for a wide glow.
1859                // Pass 1: threshold -> ping -> pong. Passes 2-4: pong -> ping -> pong.
1860                if let Some(blur_pipeline) = &self.resources.bloom_blur_pipeline {
1861                    let blur_h_bg = &slot_hdr.bloom_blur_h_bg;
1862                    let blur_h_pong_bg = &slot_hdr.bloom_blur_h_pong_bg;
1863                    let blur_v_bg = &slot_hdr.bloom_blur_v_bg;
1864                    let bloom_ping_view = &slot_hdr.bloom_ping_view;
1865                    let bloom_pong_view = &slot_hdr.bloom_pong_view;
1866                    const BLUR_ITERATIONS: usize = 4;
1867                    for i in 0..BLUR_ITERATIONS {
1868                        // H pass: pass 0 reads threshold, subsequent passes read pong.
1869                        let h_bg = if i == 0 { blur_h_bg } else { blur_h_pong_bg };
1870                        {
1871                            let mut h_pass =
1872                                encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1873                                    label: Some("bloom_blur_h_pass"),
1874                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1875                                        view: bloom_ping_view,
1876                                        resolve_target: None,
1877                                        ops: wgpu::Operations {
1878                                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1879                                            store: wgpu::StoreOp::Store,
1880                                        },
1881                                        depth_slice: None,
1882                                    })],
1883                                    depth_stencil_attachment: None,
1884                                    timestamp_writes: None,
1885                                    occlusion_query_set: None,
1886                                });
1887                            h_pass.set_pipeline(blur_pipeline);
1888                            h_pass.set_bind_group(0, h_bg, &[]);
1889                            h_pass.draw(0..3, 0..1);
1890                        }
1891                        // V pass: ping -> pong.
1892                        {
1893                            let mut v_pass =
1894                                encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1895                                    label: Some("bloom_blur_v_pass"),
1896                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1897                                        view: bloom_pong_view,
1898                                        resolve_target: None,
1899                                        ops: wgpu::Operations {
1900                                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1901                                            store: wgpu::StoreOp::Store,
1902                                        },
1903                                        depth_slice: None,
1904                                    })],
1905                                    depth_stencil_attachment: None,
1906                                    timestamp_writes: None,
1907                                    occlusion_query_set: None,
1908                                });
1909                            v_pass.set_pipeline(blur_pipeline);
1910                            v_pass.set_bind_group(0, blur_v_bg, &[]);
1911                            v_pass.draw(0..3, 0..1);
1912                        }
1913                    }
1914                }
1915            }
1916        }
1917
1918        // -----------------------------------------------------------------------
1919        // Tone map pass: HDR + bloom + AO -> (fxaa_texture if FXAA) or output_view.
1920        // -----------------------------------------------------------------------
1921        let use_fxaa = pp.fxaa;
1922        if let Some(tone_map_pipeline) = &self.resources.tone_map_pipeline {
1923            let tone_target: &wgpu::TextureView = if use_fxaa {
1924                &slot_hdr.fxaa_view
1925            } else {
1926                output_view
1927            };
1928            let mut tone_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1929                label: Some("tone_map_pass"),
1930                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1931                    view: tone_target,
1932                    resolve_target: None,
1933                    ops: wgpu::Operations {
1934                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1935                        store: wgpu::StoreOp::Store,
1936                    },
1937                    depth_slice: None,
1938                })],
1939                depth_stencil_attachment: None,
1940                timestamp_writes: None,
1941                occlusion_query_set: None,
1942            });
1943            tone_pass.set_pipeline(tone_map_pipeline);
1944            tone_pass.set_bind_group(0, &slot_hdr.tone_map_bind_group, &[]);
1945            tone_pass.draw(0..3, 0..1);
1946        }
1947
1948        // -----------------------------------------------------------------------
1949        // FXAA pass: fxaa_texture -> output_view (only when FXAA is enabled).
1950        // -----------------------------------------------------------------------
1951        if use_fxaa {
1952            if let Some(fxaa_pipeline) = &self.resources.fxaa_pipeline {
1953                let mut fxaa_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1954                    label: Some("fxaa_pass"),
1955                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1956                        view: output_view,
1957                        resolve_target: None,
1958                        ops: wgpu::Operations {
1959                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1960                            store: wgpu::StoreOp::Store,
1961                        },
1962                        depth_slice: None,
1963                    })],
1964                    depth_stencil_attachment: None,
1965                    timestamp_writes: None,
1966                    occlusion_query_set: None,
1967                });
1968                fxaa_pass.set_pipeline(fxaa_pipeline);
1969                fxaa_pass.set_bind_group(0, &slot_hdr.fxaa_bind_group, &[]);
1970                fxaa_pass.draw(0..3, 0..1);
1971            }
1972        }
1973
1974        // Grid pass (HDR path): draw the existing analytical grid on the final
1975        // output after tone mapping / FXAA, reusing the scene depth buffer so
1976        // scene geometry still occludes the grid exactly as in the LDR path.
1977        if frame.viewport.show_grid {
1978            let slot = &self.viewport_slots[vp_idx];
1979            let slot_hdr = slot.hdr.as_ref().unwrap();
1980            let grid_bg = &slot.grid_bind_group;
1981            let mut grid_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1982                label: Some("hdr_grid_pass"),
1983                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1984                    view: output_view,
1985                    resolve_target: None,
1986                    ops: wgpu::Operations {
1987                        load: wgpu::LoadOp::Load,
1988                        store: wgpu::StoreOp::Store,
1989                    },
1990                    depth_slice: None,
1991                })],
1992                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1993                    view: &slot_hdr.hdr_depth_view,
1994                    depth_ops: Some(wgpu::Operations {
1995                        load: wgpu::LoadOp::Load,
1996                        store: wgpu::StoreOp::Store,
1997                    }),
1998                    stencil_ops: None,
1999                }),
2000                timestamp_writes: None,
2001                occlusion_query_set: None,
2002            });
2003            grid_pass.set_pipeline(&self.resources.grid_pipeline);
2004            grid_pass.set_bind_group(0, grid_bg, &[]);
2005            grid_pass.draw(0..3, 0..1);
2006        }
2007
2008        // Ground plane pass (HDR path): drawn after grid, before editor overlays.
2009        // Uses the scene depth buffer for correct occlusion against geometry.
2010        if !matches!(
2011            frame.effects.ground_plane.mode,
2012            crate::renderer::types::GroundPlaneMode::None
2013        ) {
2014            let slot = &self.viewport_slots[vp_idx];
2015            let slot_hdr = slot.hdr.as_ref().unwrap();
2016            let mut gp_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2017                label: Some("hdr_ground_plane_pass"),
2018                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2019                    view: output_view,
2020                    resolve_target: None,
2021                    ops: wgpu::Operations {
2022                        load: wgpu::LoadOp::Load,
2023                        store: wgpu::StoreOp::Store,
2024                    },
2025                    depth_slice: None,
2026                })],
2027                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2028                    view: &slot_hdr.hdr_depth_view,
2029                    depth_ops: Some(wgpu::Operations {
2030                        load: wgpu::LoadOp::Load,
2031                        store: wgpu::StoreOp::Store,
2032                    }),
2033                    stencil_ops: None,
2034                }),
2035                timestamp_writes: None,
2036                occlusion_query_set: None,
2037            });
2038            gp_pass.set_pipeline(&self.resources.ground_plane_pipeline);
2039            gp_pass.set_bind_group(0, &self.resources.ground_plane_bind_group, &[]);
2040            gp_pass.draw(0..3, 0..1);
2041        }
2042
2043        // Editor overlay pass (HDR path): draw viewport/editor overlays on the
2044        // final output after tone mapping / FXAA, reusing the scene depth
2045        // buffer so depth-tested helpers still behave correctly.
2046        {
2047            let slot = &self.viewport_slots[vp_idx];
2048            let slot_hdr = slot.hdr.as_ref().unwrap();
2049            let has_editor_overlays = (frame.interaction.gizmo_model.is_some()
2050                && slot.gizmo_index_count > 0)
2051                || !slot.constraint_line_buffers.is_empty()
2052                || !slot.clip_plane_fill_buffers.is_empty()
2053                || !slot.clip_plane_line_buffers.is_empty()
2054                || !slot.xray_object_buffers.is_empty();
2055            if has_editor_overlays {
2056                let camera_bg = &slot.camera_bind_group;
2057                let mut overlay_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2058                    label: Some("hdr_editor_overlay_pass"),
2059                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2060                        view: output_view,
2061                        resolve_target: None,
2062                        ops: wgpu::Operations {
2063                            load: wgpu::LoadOp::Load,
2064                            store: wgpu::StoreOp::Store,
2065                        },
2066                        depth_slice: None,
2067                    })],
2068                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2069                        view: &slot_hdr.hdr_depth_view,
2070                        depth_ops: Some(wgpu::Operations {
2071                            load: wgpu::LoadOp::Load,
2072                            store: wgpu::StoreOp::Discard,
2073                        }),
2074                        stencil_ops: None,
2075                    }),
2076                    timestamp_writes: None,
2077                    occlusion_query_set: None,
2078                });
2079
2080                if frame.interaction.gizmo_model.is_some() && slot.gizmo_index_count > 0 {
2081                    overlay_pass.set_pipeline(&self.resources.gizmo_pipeline);
2082                    overlay_pass.set_bind_group(0, camera_bg, &[]);
2083                    overlay_pass.set_bind_group(1, &slot.gizmo_bind_group, &[]);
2084                    overlay_pass.set_vertex_buffer(0, slot.gizmo_vertex_buffer.slice(..));
2085                    overlay_pass.set_index_buffer(
2086                        slot.gizmo_index_buffer.slice(..),
2087                        wgpu::IndexFormat::Uint32,
2088                    );
2089                    overlay_pass.draw_indexed(0..slot.gizmo_index_count, 0, 0..1);
2090                }
2091
2092                if !slot.constraint_line_buffers.is_empty() {
2093                    overlay_pass.set_pipeline(&self.resources.overlay_line_pipeline);
2094                    overlay_pass.set_bind_group(0, camera_bg, &[]);
2095                    for (vbuf, ibuf, index_count, _ubuf, bg) in &slot.constraint_line_buffers {
2096                        overlay_pass.set_bind_group(1, bg, &[]);
2097                        overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
2098                        overlay_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
2099                        overlay_pass.draw_indexed(0..*index_count, 0, 0..1);
2100                    }
2101                }
2102
2103                if !slot.clip_plane_fill_buffers.is_empty() {
2104                    overlay_pass.set_pipeline(&self.resources.overlay_pipeline);
2105                    overlay_pass.set_bind_group(0, camera_bg, &[]);
2106                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.clip_plane_fill_buffers {
2107                        overlay_pass.set_bind_group(1, bg, &[]);
2108                        overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
2109                        overlay_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
2110                        overlay_pass.draw_indexed(0..*idx_count, 0, 0..1);
2111                    }
2112                }
2113
2114                if !slot.clip_plane_line_buffers.is_empty() {
2115                    overlay_pass.set_pipeline(&self.resources.overlay_line_pipeline);
2116                    overlay_pass.set_bind_group(0, camera_bg, &[]);
2117                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.clip_plane_line_buffers {
2118                        overlay_pass.set_bind_group(1, bg, &[]);
2119                        overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
2120                        overlay_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
2121                        overlay_pass.draw_indexed(0..*idx_count, 0, 0..1);
2122                    }
2123                }
2124
2125                if !slot.xray_object_buffers.is_empty() {
2126                    overlay_pass.set_pipeline(&self.resources.xray_pipeline);
2127                    overlay_pass.set_bind_group(0, camera_bg, &[]);
2128                    for (mesh_id, _buf, bg) in &slot.xray_object_buffers {
2129                        let Some(mesh) = self
2130                            .resources
2131                            .mesh_store
2132                            .get(*mesh_id)
2133                        else {
2134                            continue;
2135                        };
2136                        overlay_pass.set_bind_group(1, bg, &[]);
2137                        overlay_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
2138                        overlay_pass.set_index_buffer(
2139                            mesh.index_buffer.slice(..),
2140                            wgpu::IndexFormat::Uint32,
2141                        );
2142                        overlay_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
2143                    }
2144                }
2145            }
2146        }
2147
2148        // Axes indicator pass (HDR path): draw in screen space on the final
2149        // output after tone mapping / FXAA so it stays visible in PBR mode.
2150        if frame.viewport.show_axes_indicator {
2151            let slot = &self.viewport_slots[vp_idx];
2152            if slot.axes_vertex_count > 0 {
2153                let slot_hdr = slot.hdr.as_ref().unwrap();
2154                let mut axes_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2155                    label: Some("hdr_axes_pass"),
2156                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2157                        view: output_view,
2158                        resolve_target: None,
2159                        ops: wgpu::Operations {
2160                            load: wgpu::LoadOp::Load,
2161                            store: wgpu::StoreOp::Store,
2162                        },
2163                        depth_slice: None,
2164                    })],
2165                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2166                        view: &slot_hdr.hdr_depth_view,
2167                        depth_ops: Some(wgpu::Operations {
2168                            load: wgpu::LoadOp::Load,
2169                            store: wgpu::StoreOp::Discard,
2170                        }),
2171                        stencil_ops: None,
2172                    }),
2173                    timestamp_writes: None,
2174                    occlusion_query_set: None,
2175                });
2176                axes_pass.set_pipeline(&self.resources.axes_pipeline);
2177                axes_pass.set_vertex_buffer(0, slot.axes_vertex_buffer.slice(..));
2178                axes_pass.draw(0..slot.axes_vertex_count, 0..1);
2179            }
2180        }
2181
2182        // Phase 10B / Phase 12 : screen-space image overlay pass (HDR path).
2183        // Drawn after axes so overlays are always on top of everything.
2184        // Regular items use depth_compare: Always; depth-composite items use LessEqual.
2185        if !self.screen_image_gpu_data.is_empty() {
2186            if let Some(overlay_pipeline) = &self.resources.screen_image_pipeline {
2187                let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
2188                let dc_pipeline = self.resources.screen_image_dc_pipeline.as_ref();
2189                let mut img_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2190                    label: Some("screen_image_pass"),
2191                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2192                        view: output_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: wgpu::StoreOp::Discard,
2205                        }),
2206                        stencil_ops: None,
2207                    }),
2208                    timestamp_writes: None,
2209                    occlusion_query_set: None,
2210                });
2211                for gpu in &self.screen_image_gpu_data {
2212                    if let (Some(dc_bg), Some(dc_pipe)) = (&gpu.depth_bind_group, dc_pipeline) {
2213                        img_pass.set_pipeline(dc_pipe);
2214                        img_pass.set_bind_group(0, dc_bg, &[]);
2215                    } else {
2216                        img_pass.set_pipeline(overlay_pipeline);
2217                        img_pass.set_bind_group(0, &gpu.bind_group, &[]);
2218                    }
2219                    img_pass.draw(0..6, 0..1);
2220                }
2221            }
2222        }
2223
2224        // Overlay labels, scalar bars, rulers, and overlay images (HDR path): drawn last.
2225        let has_overlay = self.label_gpu_data.is_some()
2226            || self.scalar_bar_gpu_data.is_some()
2227            || self.ruler_gpu_data.is_some()
2228            || self.loading_bar_gpu_data.is_some()
2229            || !self.overlay_image_gpu_data.is_empty();
2230        if has_overlay {
2231            let hdr_depth_view =
2232                &self.viewport_slots[vp_idx].hdr.as_ref().unwrap().hdr_depth_view;
2233            let mut overlay_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2234                label: Some("overlay_pass"),
2235                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2236                    view: output_view,
2237                    resolve_target: None,
2238                    ops: wgpu::Operations {
2239                        load: wgpu::LoadOp::Load,
2240                        store: wgpu::StoreOp::Store,
2241                    },
2242                    depth_slice: None,
2243                })],
2244                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2245                    view: hdr_depth_view,
2246                    depth_ops: Some(wgpu::Operations {
2247                        load: wgpu::LoadOp::Load,
2248                        store: wgpu::StoreOp::Discard,
2249                    }),
2250                    stencil_ops: None,
2251                }),
2252                timestamp_writes: None,
2253                occlusion_query_set: None,
2254            });
2255            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
2256                overlay_pass.set_pipeline(pipeline);
2257                if let Some(ref ld) = self.label_gpu_data {
2258                    overlay_pass.set_bind_group(0, &ld.bind_group, &[]);
2259                    overlay_pass.set_vertex_buffer(0, ld.vertex_buf.slice(..));
2260                    overlay_pass.draw(0..ld.vertex_count, 0..1);
2261                }
2262                if let Some(ref sb) = self.scalar_bar_gpu_data {
2263                    overlay_pass.set_bind_group(0, &sb.bind_group, &[]);
2264                    overlay_pass.set_vertex_buffer(0, sb.vertex_buf.slice(..));
2265                    overlay_pass.draw(0..sb.vertex_count, 0..1);
2266                }
2267                if let Some(ref rd) = self.ruler_gpu_data {
2268                    overlay_pass.set_bind_group(0, &rd.bind_group, &[]);
2269                    overlay_pass.set_vertex_buffer(0, rd.vertex_buf.slice(..));
2270                    overlay_pass.draw(0..rd.vertex_count, 0..1);
2271                }
2272                if let Some(ref lb) = self.loading_bar_gpu_data {
2273                    overlay_pass.set_bind_group(0, &lb.bind_group, &[]);
2274                    overlay_pass.set_vertex_buffer(0, lb.vertex_buf.slice(..));
2275                    overlay_pass.draw(0..lb.vertex_count, 0..1);
2276                }
2277            }
2278            // Phase 7 : overlay images drawn last inside the overlay pass.
2279            if !self.overlay_image_gpu_data.is_empty() {
2280                if let Some(pipeline) = &self.resources.screen_image_pipeline {
2281                    overlay_pass.set_pipeline(pipeline);
2282                    for gpu in &self.overlay_image_gpu_data {
2283                        overlay_pass.set_bind_group(0, &gpu.bind_group, &[]);
2284                        overlay_pass.draw(0..6, 0..1);
2285                    }
2286                }
2287            }
2288        }
2289
2290        // Phase 4 : resolve timestamp queries -> staging buffer (HDR path).
2291        if let (Some(qs), Some(res_buf), Some(stg_buf)) = (
2292            self.ts_query_set.as_ref(),
2293            self.ts_resolve_buf.as_ref(),
2294            self.ts_staging_buf.as_ref(),
2295        ) {
2296            encoder.resolve_query_set(qs, 0..2, res_buf, 0);
2297            encoder.copy_buffer_to_buffer(res_buf, 0, stg_buf, 0, 16);
2298            self.ts_needs_readback = true;
2299        }
2300
2301        encoder.finish()
2302    }
2303
2304    /// Render a frame to an offscreen texture and return raw RGBA bytes.
2305    ///
2306    /// Creates a temporary [`wgpu::Texture`] render target of the given dimensions,
2307    /// runs all render passes (shadow, scene, post-processing) into it via
2308    /// [`render()`](Self::render), then copies the result back to CPU memory.
2309    ///
2310    /// No OS window or [`wgpu::Surface`] is required. The caller is responsible for
2311    /// initialising the wgpu adapter with `compatible_surface: None` and for
2312    /// constructing a valid [`FrameData`] (including `viewport_size` matching
2313    /// `width`/`height`).
2314    ///
2315    /// Returns `width * height * 4` bytes in RGBA8 layout. The caller encodes to
2316    /// PNG/EXR independently : no image codec dependency in this crate.
2317    pub fn render_offscreen(
2318        &mut self,
2319        device: &wgpu::Device,
2320        queue: &wgpu::Queue,
2321        frame: &FrameData,
2322        width: u32,
2323        height: u32,
2324    ) -> Vec<u8> {
2325        // 1. Create offscreen texture with RENDER_ATTACHMENT | COPY_SRC usage.
2326        let target_format = self.resources.target_format;
2327        let offscreen_texture = device.create_texture(&wgpu::TextureDescriptor {
2328            label: Some("offscreen_target"),
2329            size: wgpu::Extent3d {
2330                width: width.max(1),
2331                height: height.max(1),
2332                depth_or_array_layers: 1,
2333            },
2334            mip_level_count: 1,
2335            sample_count: 1,
2336            dimension: wgpu::TextureDimension::D2,
2337            format: target_format,
2338            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
2339            view_formats: &[],
2340        });
2341
2342        // 2. Create a texture view for rendering into.
2343        let output_view = offscreen_texture.create_view(&wgpu::TextureViewDescriptor::default());
2344
2345        // 3. render() calls ensure_viewport_hdr which provides the depth-stencil buffer
2346        //    for both LDR and HDR paths, so no separate ensure_outline_target is needed.
2347
2348        // 4. Render the scene into the offscreen texture.
2349        //    The caller must set `frame.camera.viewport_size` to `[width as f32, height as f32]`
2350        //    and `frame.camera.render_camera.aspect` to `width as f32 / height as f32`
2351        //    for correct HDR target allocation and scissor rects.
2352        let cmd_buf = self.render(device, queue, &output_view, frame);
2353        queue.submit(std::iter::once(cmd_buf));
2354
2355        // 5. Copy texture -> staging buffer (wgpu requires row alignment to 256 bytes).
2356        let bytes_per_pixel = 4u32;
2357        let unpadded_row = width * bytes_per_pixel;
2358        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
2359        let padded_row = (unpadded_row + align - 1) & !(align - 1);
2360        let buffer_size = (padded_row * height.max(1)) as u64;
2361
2362        let staging_buf = device.create_buffer(&wgpu::BufferDescriptor {
2363            label: Some("offscreen_staging"),
2364            size: buffer_size,
2365            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2366            mapped_at_creation: false,
2367        });
2368
2369        let mut copy_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
2370            label: Some("offscreen_copy_encoder"),
2371        });
2372        copy_encoder.copy_texture_to_buffer(
2373            wgpu::TexelCopyTextureInfo {
2374                texture: &offscreen_texture,
2375                mip_level: 0,
2376                origin: wgpu::Origin3d::ZERO,
2377                aspect: wgpu::TextureAspect::All,
2378            },
2379            wgpu::TexelCopyBufferInfo {
2380                buffer: &staging_buf,
2381                layout: wgpu::TexelCopyBufferLayout {
2382                    offset: 0,
2383                    bytes_per_row: Some(padded_row),
2384                    rows_per_image: Some(height.max(1)),
2385                },
2386            },
2387            wgpu::Extent3d {
2388                width: width.max(1),
2389                height: height.max(1),
2390                depth_or_array_layers: 1,
2391            },
2392        );
2393        queue.submit(std::iter::once(copy_encoder.finish()));
2394
2395        // 6. Map buffer and extract tightly-packed RGBA pixels.
2396        let (tx, rx) = std::sync::mpsc::channel();
2397        staging_buf
2398            .slice(..)
2399            .map_async(wgpu::MapMode::Read, move |result| {
2400                let _ = tx.send(result);
2401            });
2402        device
2403            .poll(wgpu::PollType::Wait {
2404                submission_index: None,
2405                timeout: Some(std::time::Duration::from_secs(5)),
2406            })
2407            .unwrap();
2408        let _ = rx.recv().unwrap_or(Err(wgpu::BufferAsyncError));
2409
2410        let mut pixels: Vec<u8> = Vec::with_capacity((width * height * 4) as usize);
2411        {
2412            let mapped = staging_buf.slice(..).get_mapped_range();
2413            let data: &[u8] = &mapped;
2414            if padded_row == unpadded_row {
2415                // No padding : copy entire slice directly.
2416                pixels.extend_from_slice(data);
2417            } else {
2418                // Strip row padding.
2419                for row in 0..height as usize {
2420                    let start = row * padded_row as usize;
2421                    let end = start + unpadded_row as usize;
2422                    pixels.extend_from_slice(&data[start..end]);
2423                }
2424            }
2425        }
2426        staging_buf.unmap();
2427
2428        // 7. Swizzle BGRA -> RGBA if the format stores bytes in BGRA order.
2429        let is_bgra = matches!(
2430            target_format,
2431            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
2432        );
2433        if is_bgra {
2434            for pixel in pixels.chunks_exact_mut(4) {
2435                pixel.swap(0, 2); // B ↔ R
2436            }
2437        }
2438
2439        pixels
2440    }
2441}