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            edl_enabled: if pp.edl_enabled { 1 } else { 0 },
863            edl_radius: pp.edl_radius,
864            edl_strength: pp.edl_strength,
865            background_color: bg_color,
866            near_plane: frame.camera.render_camera.near,
867            far_plane: frame.camera.render_camera.far,
868            lic_enabled: if frame.scene.lic_items.is_empty() { 0 } else { 1 },
869            lic_strength: frame.scene.lic_items.first().map(|i| i.config.strength).unwrap_or(0.5),
870        };
871        {
872            let hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
873            queue.write_buffer(
874                &hdr.tone_map_uniform_buf,
875                0,
876                bytemuck::cast_slice(&[tm_uniform]),
877            );
878
879            // Upload SSAO uniform if needed.
880            if pp.ssao {
881                let proj = frame.camera.render_camera.projection;
882                let inv_proj = proj.inverse();
883                let ssao_uniform = crate::resources::SsaoUniform {
884                    inv_proj: inv_proj.to_cols_array_2d(),
885                    proj: proj.to_cols_array_2d(),
886                    radius: 0.5,
887                    bias: 0.025,
888                    _pad: [0.0; 2],
889                };
890                queue.write_buffer(
891                    &hdr.ssao_uniform_buf,
892                    0,
893                    bytemuck::cast_slice(&[ssao_uniform]),
894                );
895            }
896
897            // Upload contact shadow uniform if needed.
898            if pp.contact_shadows {
899                let proj = frame.camera.render_camera.projection;
900                let inv_proj = proj.inverse();
901                let light_dir_world: glam::Vec3 =
902                    if let Some(l) = frame.effects.lighting.lights.first() {
903                        match l.kind {
904                            LightKind::Directional { direction } => {
905                                glam::Vec3::from(direction).normalize()
906                            }
907                            LightKind::Spot { direction, .. } => {
908                                glam::Vec3::from(direction).normalize()
909                            }
910                            _ => glam::Vec3::new(0.0, -1.0, 0.0),
911                        }
912                    } else {
913                        glam::Vec3::new(0.0, -1.0, 0.0)
914                    };
915                let view = frame.camera.render_camera.view;
916                let light_dir_view = view.transform_vector3(light_dir_world).normalize();
917                let world_up_view = view.transform_vector3(glam::Vec3::Z).normalize();
918                let cs_uniform = crate::resources::ContactShadowUniform {
919                    inv_proj: inv_proj.to_cols_array_2d(),
920                    proj: proj.to_cols_array_2d(),
921                    light_dir_view: [light_dir_view.x, light_dir_view.y, light_dir_view.z, 0.0],
922                    world_up_view: [world_up_view.x, world_up_view.y, world_up_view.z, 0.0],
923                    params: [
924                        pp.contact_shadow_max_distance,
925                        pp.contact_shadow_steps as f32,
926                        pp.contact_shadow_thickness,
927                        0.0,
928                    ],
929                };
930                queue.write_buffer(
931                    &hdr.contact_shadow_uniform_buf,
932                    0,
933                    bytemuck::cast_slice(&[cs_uniform]),
934                );
935            }
936
937            // Upload bloom uniform if needed.
938            if pp.bloom {
939                let bloom_u = crate::resources::BloomUniform {
940                    threshold: pp.bloom_threshold,
941                    intensity: pp.bloom_intensity,
942                    horizontal: 0,
943                    _pad: 0,
944                };
945                queue.write_buffer(&hdr.bloom_uniform_buf, 0, bytemuck::cast_slice(&[bloom_u]));
946            }
947        }
948
949        // Rebuild tone-map bind group with correct bloom/AO texture views.
950        {
951            let hdr = self.viewport_slots[vp_idx].hdr.as_mut().unwrap();
952            self.resources.rebuild_tone_map_bind_group(
953                device,
954                hdr,
955                pp.bloom,
956                pp.ssao,
957                pp.contact_shadows,
958                !frame.scene.lic_items.is_empty(),
959            );
960        }
961
962        // -----------------------------------------------------------------------
963        // Pre-allocate OIT targets if any transparent items exist.
964        // Must happen before camera_bg is borrowed (borrow-checker constraint).
965        // -----------------------------------------------------------------------
966        {
967            let needs_oit = if self.use_instancing && !self.instanced_batches.is_empty() {
968                self.instanced_batches.iter().any(|b| b.is_transparent)
969            } else {
970                scene_items
971                    .iter()
972                    .any(|i| i.visible && i.material.opacity < 1.0)
973            } || frame.scene.transparent_volume_meshes.iter().any(|i| i.visible);
974            if needs_oit {
975                let hdr = self.viewport_slots[vp_idx].hdr.as_mut().unwrap();
976                self.resources
977                    .ensure_viewport_oit(device, hdr, w.max(1), h.max(1));
978            }
979        }
980
981        // -----------------------------------------------------------------------
982        // Build the command encoder.
983        // -----------------------------------------------------------------------
984        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
985            label: Some("hdr_encoder"),
986        });
987
988        // Per-viewport camera bind group and HDR state for the HDR path.
989        let slot = &self.viewport_slots[vp_idx];
990        let camera_bg = &slot.camera_bind_group;
991        let slot_hdr = slot.hdr.as_ref().unwrap();
992
993        // -----------------------------------------------------------------------
994        // HDR scene pass: render geometry into the HDR texture.
995        // -----------------------------------------------------------------------
996        {
997            // Use SSAA target if enabled, otherwise render directly to hdr_texture.
998            let use_ssaa = ssaa_factor > 1
999                && slot_hdr.ssaa_color_view.is_some()
1000                && slot_hdr.ssaa_depth_view.is_some();
1001            let scene_color_view = if use_ssaa {
1002                slot_hdr.ssaa_color_view.as_ref().unwrap()
1003            } else {
1004                &slot_hdr.hdr_view
1005            };
1006            let scene_depth_view = if use_ssaa {
1007                slot_hdr.ssaa_depth_view.as_ref().unwrap()
1008            } else {
1009                &slot_hdr.hdr_depth_view
1010            };
1011
1012            let clear_wgpu = wgpu::Color {
1013                r: hdr_clear_rgb[0] as f64,
1014                g: hdr_clear_rgb[1] as f64,
1015                b: hdr_clear_rgb[2] as f64,
1016                // Clear alpha to 0.0 so OIT composite can signal presence via alpha > 0.
1017                // Background pixels remain at alpha=0 and are detected in tone_map.wgsl.
1018                a: 0.0,
1019            };
1020
1021            let hdr_ts_writes = self.ts_query_set.as_ref().map(|qs| {
1022                wgpu::RenderPassTimestampWrites {
1023                    query_set: qs,
1024                    beginning_of_pass_write_index: Some(0),
1025                    end_of_pass_write_index: Some(1),
1026                }
1027            });
1028            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1029                label: Some("hdr_scene_pass"),
1030                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1031                    view: scene_color_view,
1032                    resolve_target: None,
1033                    ops: wgpu::Operations {
1034                        load: wgpu::LoadOp::Clear(clear_wgpu),
1035                        store: wgpu::StoreOp::Store,
1036                    },
1037                    depth_slice: None,
1038                })],
1039                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1040                    view: scene_depth_view,
1041                    depth_ops: Some(wgpu::Operations {
1042                        load: wgpu::LoadOp::Clear(1.0),
1043                        store: wgpu::StoreOp::Store,
1044                    }),
1045                    stencil_ops: Some(wgpu::Operations {
1046                        load: wgpu::LoadOp::Clear(0),
1047                        store: wgpu::StoreOp::Store,
1048                    }),
1049                }),
1050                timestamp_writes: hdr_ts_writes,
1051                occlusion_query_set: None,
1052            });
1053
1054            let resources = &self.resources;
1055            render_pass.set_bind_group(0, camera_bg, &[]);
1056
1057            // Check skybox eligibility early; drawn after all opaques below.
1058            let show_skybox = frame
1059                .effects
1060                .environment
1061                .as_ref()
1062                .is_some_and(|e| e.show_skybox)
1063                && resources.ibl_skybox_view.is_some();
1064
1065            let use_instancing = self.use_instancing;
1066            let batches = &self.instanced_batches;
1067
1068            if !scene_items.is_empty() {
1069                if use_instancing && !batches.is_empty() {
1070                    let excluded_items: Vec<&SceneRenderItem> = scene_items
1071                        .iter()
1072                        .filter(|item| {
1073                            item.visible
1074                                && (item.active_attribute.is_some()
1075                                    || item.material.is_two_sided()
1076                                    || item.material.matcap_id.is_some())
1077                                && resources
1078                                    .mesh_store
1079                                    .get(item.mesh_id)
1080                                    .is_some()
1081                        })
1082                        .collect();
1083
1084                    // Separate opaque and transparent batches.
1085                    // Carry the global batch index (position in `batches`) alongside each batch
1086                    // so draw_indexed_indirect can compute the correct buffer offset.
1087                    let mut opaque_batches: Vec<(usize, &InstancedBatch)> = Vec::new();
1088                    let mut transparent_batches: Vec<(usize, &InstancedBatch)> = Vec::new();
1089                    for (batch_global_idx, batch) in batches.iter().enumerate() {
1090                        if batch.is_transparent {
1091                            transparent_batches.push((batch_global_idx, batch));
1092                        } else {
1093                            opaque_batches.push((batch_global_idx, batch));
1094                        }
1095                    }
1096
1097                    if !opaque_batches.is_empty() && !frame.viewport.wireframe_mode {
1098                        let use_indirect = self.gpu_culling_enabled
1099                            && resources.hdr_solid_instanced_cull_pipeline.is_some()
1100                            && resources.indirect_args_buf.is_some();
1101
1102                        if use_indirect {
1103                            if let (
1104                                Some(pipeline),
1105                                Some(indirect_buf),
1106                            ) = (
1107                                &resources.hdr_solid_instanced_cull_pipeline,
1108                                &resources.indirect_args_buf,
1109                            ) {
1110                                render_pass.set_pipeline(pipeline);
1111                                for (batch_global_idx, batch) in &opaque_batches {
1112                                    let Some(mesh) = resources.mesh_store.get(batch.mesh_id)
1113                                    else {
1114                                        continue;
1115                                    };
1116                                    let mat_key = (
1117                                        batch.texture_id.unwrap_or(u64::MAX),
1118                                        batch.normal_map_id.unwrap_or(u64::MAX),
1119                                        batch.ao_map_id.unwrap_or(u64::MAX),
1120                                    );
1121                                    let Some(inst_tex_bg) =
1122                                        resources.instance_cull_bind_groups.get(&mat_key)
1123                                    else {
1124                                        continue;
1125                                    };
1126                                    render_pass.set_bind_group(1, inst_tex_bg, &[]);
1127                                    render_pass.set_vertex_buffer(
1128                                        0,
1129                                        mesh.vertex_buffer.slice(..),
1130                                    );
1131                                    render_pass.set_index_buffer(
1132                                        mesh.index_buffer.slice(..),
1133                                        wgpu::IndexFormat::Uint32,
1134                                    );
1135                                    // Each DrawIndexedIndirect entry is 20 bytes; index by global
1136                                    // batch position so the offset matches write_indirect_args output.
1137                                    render_pass.draw_indexed_indirect(
1138                                        indirect_buf,
1139                                        *batch_global_idx as u64 * 20,
1140                                    );
1141                                }
1142                            }
1143                        } else if let Some(ref pipeline) = resources.hdr_solid_instanced_pipeline {
1144                            render_pass.set_pipeline(pipeline);
1145                            for (_, batch) in &opaque_batches {
1146                                let Some(mesh) = resources
1147                                    .mesh_store
1148                                    .get(batch.mesh_id)
1149                                else {
1150                                    continue;
1151                                };
1152                                let mat_key = (
1153                                    batch.texture_id.unwrap_or(u64::MAX),
1154                                    batch.normal_map_id.unwrap_or(u64::MAX),
1155                                    batch.ao_map_id.unwrap_or(u64::MAX),
1156                                );
1157                                let Some(inst_tex_bg) =
1158                                    resources.instance_bind_groups.get(&mat_key)
1159                                else {
1160                                    continue;
1161                                };
1162                                render_pass.set_bind_group(1, inst_tex_bg, &[]);
1163                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1164                                render_pass.set_index_buffer(
1165                                    mesh.index_buffer.slice(..),
1166                                    wgpu::IndexFormat::Uint32,
1167                                );
1168                                render_pass.draw_indexed(
1169                                    0..mesh.index_count,
1170                                    0,
1171                                    batch.instance_offset
1172                                        ..batch.instance_offset + batch.instance_count,
1173                                );
1174                            }
1175                        }
1176                    }
1177
1178                    // NOTE: transparent_batches are now rendered in the OIT pass below,
1179                    // not in the HDR scene pass. This block intentionally left empty.
1180                    let _ = &transparent_batches; // suppress unused warning
1181
1182                    if frame.viewport.wireframe_mode {
1183                        if let Some(ref hdr_wf) = resources.hdr_wireframe_pipeline {
1184                            render_pass.set_pipeline(hdr_wf);
1185                            for item in scene_items {
1186                                if !item.visible {
1187                                    continue;
1188                                }
1189                                let Some(mesh) = resources
1190                                    .mesh_store
1191                                    .get(item.mesh_id)
1192                                else {
1193                                    continue;
1194                                };
1195                                render_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
1196                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1197                                render_pass.set_index_buffer(
1198                                    mesh.edge_index_buffer.slice(..),
1199                                    wgpu::IndexFormat::Uint32,
1200                                );
1201                                render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
1202                            }
1203                        }
1204                    } else if let (Some(hdr_solid), Some(hdr_solid_two_sided)) = (
1205                        &resources.hdr_solid_pipeline,
1206                        &resources.hdr_solid_two_sided_pipeline,
1207                    ) {
1208                        for item in excluded_items
1209                            .into_iter()
1210                            .filter(|item| item.material.opacity >= 1.0)
1211                        {
1212                            let Some(mesh) = resources
1213                                .mesh_store
1214                                .get(item.mesh_id)
1215                            else {
1216                                continue;
1217                            };
1218                            let pipeline = if item.material.is_two_sided() {
1219                                hdr_solid_two_sided
1220                            } else {
1221                                hdr_solid
1222                            };
1223                            render_pass.set_pipeline(pipeline);
1224                            render_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
1225                            render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1226                            render_pass.set_index_buffer(
1227                                mesh.index_buffer.slice(..),
1228                                wgpu::IndexFormat::Uint32,
1229                            );
1230                            render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1231                        }
1232                    }
1233                } else {
1234                    // Per-object path.
1235                    let eye = glam::Vec3::from(frame.camera.render_camera.eye_position);
1236                    let dist_from_eye = |item: &&SceneRenderItem| -> f32 {
1237                        let pos =
1238                            glam::Vec3::new(item.model[3][0], item.model[3][1], item.model[3][2]);
1239                        (pos - eye).length()
1240                    };
1241
1242                    let mut opaque: Vec<&SceneRenderItem> = Vec::new();
1243                    let mut transparent: Vec<&SceneRenderItem> = Vec::new();
1244                    for item in scene_items {
1245                        if !item.visible
1246                            || resources
1247                                .mesh_store
1248                                .get(item.mesh_id)
1249                                .is_none()
1250                        {
1251                            continue;
1252                        }
1253                        if item.material.opacity < 1.0 {
1254                            transparent.push(item);
1255                        } else {
1256                            opaque.push(item);
1257                        }
1258                    }
1259                    opaque.sort_by(|a, b| {
1260                        dist_from_eye(a)
1261                            .partial_cmp(&dist_from_eye(b))
1262                            .unwrap_or(std::cmp::Ordering::Equal)
1263                    });
1264                    transparent.sort_by(|a, b| {
1265                        dist_from_eye(b)
1266                            .partial_cmp(&dist_from_eye(a))
1267                            .unwrap_or(std::cmp::Ordering::Equal)
1268                    });
1269
1270                    let draw_item_hdr =
1271                        |render_pass: &mut wgpu::RenderPass<'_>,
1272                         item: &SceneRenderItem,
1273                         solid_pl: &wgpu::RenderPipeline,
1274                         trans_pl: &wgpu::RenderPipeline,
1275                         wf_pl: &wgpu::RenderPipeline| {
1276                            let mesh = resources
1277                                .mesh_store
1278                                .get(item.mesh_id)
1279                                .unwrap();
1280                            // mesh.object_bind_group (group 1) already carries the object uniform
1281                            // and the correct texture views.
1282                            render_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
1283                            let is_face_attr = item.active_attribute.as_ref().map_or(false, |a| {
1284                                matches!(
1285                                    a.kind,
1286                                    crate::resources::AttributeKind::Face
1287                                        | crate::resources::AttributeKind::FaceColor
1288                                        | crate::resources::AttributeKind::Halfedge
1289                                        | crate::resources::AttributeKind::Corner
1290                                )
1291                            });
1292                            if frame.viewport.wireframe_mode {
1293                                render_pass.set_pipeline(wf_pl);
1294                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1295                                render_pass.set_index_buffer(
1296                                    mesh.edge_index_buffer.slice(..),
1297                                    wgpu::IndexFormat::Uint32,
1298                                );
1299                                render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
1300                            } else if is_face_attr {
1301                                if let Some(ref fvb) = mesh.face_vertex_buffer {
1302                                    let pl = if item.material.opacity < 1.0 {
1303                                        trans_pl
1304                                    } else {
1305                                        solid_pl
1306                                    };
1307                                    render_pass.set_pipeline(pl);
1308                                    render_pass.set_vertex_buffer(0, fvb.slice(..));
1309                                    render_pass.draw(0..mesh.index_count, 0..1);
1310                                }
1311                            } else if item.material.opacity < 1.0 {
1312                                render_pass.set_pipeline(trans_pl);
1313                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1314                                render_pass.set_index_buffer(
1315                                    mesh.index_buffer.slice(..),
1316                                    wgpu::IndexFormat::Uint32,
1317                                );
1318                                render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1319                            } else {
1320                                render_pass.set_pipeline(solid_pl);
1321                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1322                                render_pass.set_index_buffer(
1323                                    mesh.index_buffer.slice(..),
1324                                    wgpu::IndexFormat::Uint32,
1325                                );
1326                                render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1327                            }
1328                        };
1329
1330                    // NOTE: only opaque items are drawn here. Transparent items are
1331                    // routed to the OIT pass below.
1332                    let _ = &transparent; // suppress unused warning
1333                    if let (
1334                        Some(hdr_solid),
1335                        Some(hdr_solid_two_sided),
1336                        Some(hdr_trans),
1337                        Some(hdr_wf),
1338                    ) = (
1339                        &resources.hdr_solid_pipeline,
1340                        &resources.hdr_solid_two_sided_pipeline,
1341                        &resources.hdr_transparent_pipeline,
1342                        &resources.hdr_wireframe_pipeline,
1343                    ) {
1344                        for item in &opaque {
1345                            let solid_pl = if item.material.is_two_sided() {
1346                                hdr_solid_two_sided
1347                            } else {
1348                                hdr_solid
1349                            };
1350                            draw_item_hdr(&mut render_pass, item, solid_pl, hdr_trans, hdr_wf);
1351                        }
1352                    }
1353                }
1354            }
1355
1356            // Cap fill pass (HDR path : section view cross-section fill).
1357            if !slot.cap_buffers.is_empty() {
1358                if let Some(ref hdr_overlay) = resources.hdr_overlay_pipeline {
1359                    render_pass.set_pipeline(hdr_overlay);
1360                    render_pass.set_bind_group(0, camera_bg, &[]);
1361                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.cap_buffers {
1362                        render_pass.set_bind_group(1, bg, &[]);
1363                        render_pass.set_vertex_buffer(0, vbuf.slice(..));
1364                        render_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
1365                        render_pass.draw_indexed(0..*idx_count, 0, 0..1);
1366                    }
1367                }
1368            }
1369
1370            // SciVis Phase B+D+M8+M: point cloud, glyph, polyline, volume, streamtube (HDR path).
1371            emit_scivis_draw_calls!(
1372                &self.resources,
1373                &mut render_pass,
1374                &self.point_cloud_gpu_data,
1375                &self.glyph_gpu_data,
1376                &self.polyline_gpu_data,
1377                &self.volume_gpu_data,
1378                &self.streamtube_gpu_data,
1379                camera_bg
1380            );
1381
1382            // Phase 16 : GPU implicit surface (HDR path, before skybox).
1383            if !self.implicit_gpu_data.is_empty() {
1384                if let Some(pipeline) = &self.resources.implicit_pipeline {
1385                    render_pass.set_pipeline(pipeline);
1386                    render_pass.set_bind_group(0, camera_bg, &[]);
1387                    for gpu in &self.implicit_gpu_data {
1388                        render_pass.set_bind_group(1, &gpu.bind_group, &[]);
1389                        render_pass.draw(0..6, 0..1);
1390                    }
1391                }
1392            }
1393            // Phase 17 : GPU marching cubes indirect draw (HDR path).
1394            if !self.mc_gpu_data.is_empty() {
1395                if let Some(pipeline) = &self.resources.mc_surface_pipeline {
1396                    render_pass.set_pipeline(pipeline);
1397                    render_pass.set_bind_group(0, camera_bg, &[]);
1398                    for mc in &self.mc_gpu_data {
1399                        let vol = &self.resources.mc_volumes[mc.volume_idx];
1400                        render_pass.set_bind_group(1, &mc.render_bg, &[]);
1401                        for slab in &vol.slabs {
1402                            render_pass.set_vertex_buffer(0, slab.vertex_buf.slice(..));
1403                            render_pass.draw_indirect(&slab.indirect_buf, 0);
1404                        }
1405                    }
1406                }
1407            }
1408
1409            // Draw skybox last among opaques : only uncovered sky pixels pass depth == 1.0.
1410            if show_skybox {
1411                render_pass.set_bind_group(0, camera_bg, &[]);
1412                render_pass.set_pipeline(&resources.skybox_pipeline);
1413                render_pass.draw(0..3, 0..1);
1414            }
1415        }
1416
1417        // -----------------------------------------------------------------------
1418        // SSAA resolve pass: downsample supersampled scene -> hdr_texture.
1419        // Only runs when ssaa_factor > 1 and the resolve pipeline is available.
1420        // -----------------------------------------------------------------------
1421        if ssaa_factor > 1 {
1422            let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
1423            if let (Some(pipeline), Some(bg)) = (
1424                &self.resources.ssaa_resolve_pipeline,
1425                &slot_hdr.ssaa_resolve_bind_group,
1426            ) {
1427                let mut resolve_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1428                    label: Some("ssaa_resolve_pass"),
1429                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1430                        view: &slot_hdr.hdr_view,
1431                        resolve_target: None,
1432                        ops: wgpu::Operations {
1433                            load: wgpu::LoadOp::Load,
1434                            store: wgpu::StoreOp::Store,
1435                        },
1436                        depth_slice: None,
1437                    })],
1438                    depth_stencil_attachment: None,
1439                    timestamp_writes: None,
1440                    occlusion_query_set: None,
1441                });
1442                resolve_pass.set_pipeline(pipeline);
1443                resolve_pass.set_bind_group(0, bg, &[]);
1444                resolve_pass.draw(0..3, 0..1);
1445            }
1446        }
1447
1448        // -----------------------------------------------------------------------
1449        // Sub-object highlight pass: face fill, edge lines, vertex sprites.
1450        // Runs after opaque geometry (depth buffer is ready) and before OIT so
1451        // highlights are not occluded by opaque surfaces.
1452        // -----------------------------------------------------------------------
1453        if let Some(sub_hl) = self.viewport_slots[vp_idx].sub_highlight.as_ref() {
1454            let resources = &self.resources;
1455            if let (Some(fill_pl), Some(edge_pl), Some(sprite_pl)) = (
1456                &resources.sub_highlight_fill_pipeline,
1457                &resources.sub_highlight_edge_pipeline,
1458                &resources.sub_highlight_sprite_pipeline,
1459            ) {
1460                let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
1461                let camera_bg = &self.viewport_slots[vp_idx].camera_bind_group;
1462                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1463                    label: Some("sub_highlight_pass"),
1464                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1465                        view: &slot_hdr.hdr_view,
1466                        resolve_target: None,
1467                        ops: wgpu::Operations {
1468                            load: wgpu::LoadOp::Load,
1469                            store: wgpu::StoreOp::Store,
1470                        },
1471                        depth_slice: None,
1472                    })],
1473                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1474                        view: &slot_hdr.hdr_depth_view,
1475                        depth_ops: Some(wgpu::Operations {
1476                            load: wgpu::LoadOp::Load,
1477                            store: wgpu::StoreOp::Discard,
1478                        }),
1479                        stencil_ops: None,
1480                    }),
1481                    timestamp_writes: None,
1482                    occlusion_query_set: None,
1483                });
1484
1485                if sub_hl.fill_vertex_count > 0 {
1486                    pass.set_pipeline(fill_pl);
1487                    pass.set_bind_group(0, camera_bg, &[]);
1488                    pass.set_bind_group(1, &sub_hl.fill_bind_group, &[]);
1489                    pass.set_vertex_buffer(0, sub_hl.fill_vertex_buf.slice(..));
1490                    pass.draw(0..sub_hl.fill_vertex_count, 0..1);
1491                }
1492                if sub_hl.edge_segment_count > 0 {
1493                    pass.set_pipeline(edge_pl);
1494                    pass.set_bind_group(0, camera_bg, &[]);
1495                    pass.set_bind_group(1, &sub_hl.edge_bind_group, &[]);
1496                    pass.set_vertex_buffer(0, sub_hl.edge_vertex_buf.slice(..));
1497                    pass.draw(0..6, 0..sub_hl.edge_segment_count);
1498                }
1499                if sub_hl.sprite_point_count > 0 {
1500                    pass.set_pipeline(sprite_pl);
1501                    pass.set_bind_group(0, camera_bg, &[]);
1502                    pass.set_bind_group(1, &sub_hl.sprite_bind_group, &[]);
1503                    pass.set_vertex_buffer(0, sub_hl.sprite_vertex_buf.slice(..));
1504                    pass.draw(0..6, 0..sub_hl.sprite_point_count);
1505                }
1506            }
1507        }
1508
1509        // -----------------------------------------------------------------------
1510        // OIT pass: render transparent items into accum + reveal textures.
1511        // Completely skipped when no transparent items exist (zero overhead).
1512        // -----------------------------------------------------------------------
1513        let has_transparent = if self.use_instancing && !self.instanced_batches.is_empty() {
1514            self.instanced_batches.iter().any(|b| b.is_transparent)
1515        } else {
1516            scene_items
1517                .iter()
1518                .any(|i| i.visible && i.material.opacity < 1.0)
1519        } || frame.scene.transparent_volume_meshes.iter().any(|i| i.visible);
1520
1521        if has_transparent {
1522            // OIT targets already allocated in the pre-pass above.
1523            if let (Some(accum_view), Some(reveal_view)) = (
1524                slot_hdr.oit_accum_view.as_ref(),
1525                slot_hdr.oit_reveal_view.as_ref(),
1526            ) {
1527                let hdr_depth_view = &slot_hdr.hdr_depth_view;
1528                // Clear accum to (0,0,0,0), reveal to 1.0 (no contribution yet).
1529                let mut oit_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1530                    label: Some("oit_pass"),
1531                    color_attachments: &[
1532                        Some(wgpu::RenderPassColorAttachment {
1533                            view: accum_view,
1534                            resolve_target: None,
1535                            ops: wgpu::Operations {
1536                                load: wgpu::LoadOp::Clear(wgpu::Color {
1537                                    r: 0.0,
1538                                    g: 0.0,
1539                                    b: 0.0,
1540                                    a: 0.0,
1541                                }),
1542                                store: wgpu::StoreOp::Store,
1543                            },
1544                            depth_slice: None,
1545                        }),
1546                        Some(wgpu::RenderPassColorAttachment {
1547                            view: reveal_view,
1548                            resolve_target: None,
1549                            ops: wgpu::Operations {
1550                                load: wgpu::LoadOp::Clear(wgpu::Color {
1551                                    r: 1.0,
1552                                    g: 1.0,
1553                                    b: 1.0,
1554                                    a: 1.0,
1555                                }),
1556                                store: wgpu::StoreOp::Store,
1557                            },
1558                            depth_slice: None,
1559                        }),
1560                    ],
1561                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1562                        view: hdr_depth_view,
1563                        depth_ops: Some(wgpu::Operations {
1564                            load: wgpu::LoadOp::Load, // reuse opaque depth
1565                            store: wgpu::StoreOp::Store,
1566                        }),
1567                        stencil_ops: None,
1568                    }),
1569                    timestamp_writes: None,
1570                    occlusion_query_set: None,
1571                });
1572
1573                oit_pass.set_bind_group(0, camera_bg, &[]);
1574
1575                if self.use_instancing && !self.instanced_batches.is_empty() {
1576                    let use_indirect_oit = self.gpu_culling_enabled
1577                        && self.resources.oit_instanced_cull_pipeline.is_some()
1578                        && self.resources.indirect_args_buf.is_some();
1579
1580                    if use_indirect_oit {
1581                        if let (
1582                            Some(pipeline),
1583                            Some(indirect_buf),
1584                        ) = (
1585                            &self.resources.oit_instanced_cull_pipeline,
1586                            &self.resources.indirect_args_buf,
1587                        ) {
1588                            oit_pass.set_pipeline(pipeline);
1589                            for (batch_global_idx, batch) in
1590                                self.instanced_batches.iter().enumerate()
1591                            {
1592                                if !batch.is_transparent {
1593                                    continue;
1594                                }
1595                                let Some(mesh) =
1596                                    self.resources.mesh_store.get(batch.mesh_id)
1597                                else {
1598                                    continue;
1599                                };
1600                                let mat_key = (
1601                                    batch.texture_id.unwrap_or(u64::MAX),
1602                                    batch.normal_map_id.unwrap_or(u64::MAX),
1603                                    batch.ao_map_id.unwrap_or(u64::MAX),
1604                                );
1605                                let Some(inst_tex_bg) =
1606                                    self.resources.instance_cull_bind_groups.get(&mat_key)
1607                                else {
1608                                    continue;
1609                                };
1610                                oit_pass.set_bind_group(1, inst_tex_bg, &[]);
1611                                oit_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1612                                oit_pass.set_index_buffer(
1613                                    mesh.index_buffer.slice(..),
1614                                    wgpu::IndexFormat::Uint32,
1615                                );
1616                                oit_pass.draw_indexed_indirect(
1617                                    indirect_buf,
1618                                    batch_global_idx as u64 * 20,
1619                                );
1620                            }
1621                        }
1622                    } else if let Some(ref pipeline) = self.resources.oit_instanced_pipeline {
1623                        oit_pass.set_pipeline(pipeline);
1624                        for batch in &self.instanced_batches {
1625                            if !batch.is_transparent {
1626                                continue;
1627                            }
1628                            let Some(mesh) = self
1629                                .resources
1630                                .mesh_store
1631                                .get(batch.mesh_id)
1632                            else {
1633                                continue;
1634                            };
1635                            let mat_key = (
1636                                batch.texture_id.unwrap_or(u64::MAX),
1637                                batch.normal_map_id.unwrap_or(u64::MAX),
1638                                batch.ao_map_id.unwrap_or(u64::MAX),
1639                            );
1640                            let Some(inst_tex_bg) =
1641                                self.resources.instance_bind_groups.get(&mat_key)
1642                            else {
1643                                continue;
1644                            };
1645                            oit_pass.set_bind_group(1, inst_tex_bg, &[]);
1646                            oit_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1647                            oit_pass.set_index_buffer(
1648                                mesh.index_buffer.slice(..),
1649                                wgpu::IndexFormat::Uint32,
1650                            );
1651                            oit_pass.draw_indexed(
1652                                0..mesh.index_count,
1653                                0,
1654                                batch.instance_offset..batch.instance_offset + batch.instance_count,
1655                            );
1656                        }
1657                    }
1658                } else if let Some(ref pipeline) = self.resources.oit_pipeline {
1659                    oit_pass.set_pipeline(pipeline);
1660                    for item in scene_items {
1661                        if !item.visible || item.material.opacity >= 1.0 {
1662                            continue;
1663                        }
1664                        let Some(mesh) = self
1665                            .resources
1666                            .mesh_store
1667                            .get(item.mesh_id)
1668                        else {
1669                            continue;
1670                        };
1671                        oit_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
1672                        oit_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1673                        oit_pass.set_index_buffer(
1674                            mesh.index_buffer.slice(..),
1675                            wgpu::IndexFormat::Uint32,
1676                        );
1677                        oit_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1678                    }
1679                }
1680
1681                // -----------------------------------------------------------
1682                // Projected tetrahedra transparent volume meshes (Phase 6).
1683                // -----------------------------------------------------------
1684                if !frame.scene.transparent_volume_meshes.is_empty() {
1685                    self.resources.ensure_pt_pipeline(device);
1686                    if let Some(pipeline) = self.resources.pt_pipeline.as_ref() {
1687                        oit_pass.set_pipeline(pipeline);
1688                        oit_pass.set_bind_group(0, camera_bg, &[]);
1689                        for item in &frame.scene.transparent_volume_meshes {
1690                            if !item.visible {
1691                                continue;
1692                            }
1693                            let Some(gpu) =
1694                                self.resources.projected_tet_store.get(item.id.0)
1695                            else {
1696                                continue;
1697                            };
1698                            let (scalar_min, scalar_max) =
1699                                item.scalar_range.unwrap_or(gpu.scalar_range);
1700                            let uniform = crate::resources::ProjectedTetUniform {
1701                                density: item.density,
1702                                scalar_min,
1703                                scalar_max,
1704                                _pad: 0.0,
1705                            };
1706                            queue.write_buffer(
1707                                &gpu.uniform_buffer,
1708                                0,
1709                                bytemuck::bytes_of(&uniform),
1710                            );
1711                            oit_pass.set_bind_group(1, &gpu.bind_group, &[]);
1712                            oit_pass.draw(0..6, 0..gpu.tet_count);
1713                        }
1714                    }
1715                }
1716            }
1717        }
1718
1719        // -----------------------------------------------------------------------
1720        // OIT composite pass: blend accum/reveal into HDR buffer.
1721        // Only executes when transparent items were present.
1722        // -----------------------------------------------------------------------
1723        if has_transparent {
1724            if let (Some(pipeline), Some(bg)) = (
1725                self.resources.oit_composite_pipeline.as_ref(),
1726                slot_hdr.oit_composite_bind_group.as_ref(),
1727            ) {
1728                let hdr_view = &slot_hdr.hdr_view;
1729                let mut composite_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1730                    label: Some("oit_composite_pass"),
1731                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1732                        view: hdr_view,
1733                        resolve_target: None,
1734                        ops: wgpu::Operations {
1735                            load: wgpu::LoadOp::Load,
1736                            store: wgpu::StoreOp::Store,
1737                        },
1738                        depth_slice: None,
1739                    })],
1740                    depth_stencil_attachment: None,
1741                    timestamp_writes: None,
1742                    occlusion_query_set: None,
1743                });
1744                composite_pass.set_pipeline(pipeline);
1745                composite_pass.set_bind_group(0, bg, &[]);
1746                composite_pass.draw(0..3, 0..1);
1747            }
1748        }
1749
1750        // -----------------------------------------------------------------------
1751        // Phase 4: Surface LIC passes.
1752        // Pass 1: render each LIC mesh into lic_vector_texture (Rgba8Unorm).
1753        // Pass 2: advect fullscreen triangle into lic_output_texture (R8Unorm).
1754        // -----------------------------------------------------------------------
1755        if !self.lic_gpu_data.is_empty() {
1756            if let (Some(surface_pipeline), Some(advect_pipeline)) = (
1757                self.resources.lic_surface_pipeline.as_ref(),
1758                self.resources.lic_advect_pipeline.as_ref(),
1759            ) {
1760                let camera_bg = &slot.camera_bind_group;
1761                // Pass 1: surface vector pass (clears lic_vector_texture first).
1762                {
1763                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1764                        label: Some("lic_surface_pass"),
1765                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1766                            view: &slot_hdr.lic_vector_view,
1767                            resolve_target: None,
1768                            ops: wgpu::Operations {
1769                                load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
1770                                store: wgpu::StoreOp::Store,
1771                            },
1772                            depth_slice: None,
1773                        })],
1774                        depth_stencil_attachment: None,
1775                        timestamp_writes: None,
1776                        occlusion_query_set: None,
1777                    });
1778                    pass.set_pipeline(surface_pipeline);
1779                    pass.set_bind_group(0, camera_bg, &[]);
1780                    for gpu in &self.lic_gpu_data {
1781                        let Some(mesh) = self.resources.mesh_store.get(gpu.mesh_id) else {
1782                            continue;
1783                        };
1784                        let Some(vec_buf) = mesh.vector_attribute_buffers.get(&gpu.vector_attribute) else {
1785                            continue;
1786                        };
1787                        pass.set_bind_group(1, &gpu.bind_group, &[]);
1788                        pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1789                        pass.set_vertex_buffer(1, vec_buf.slice(..));
1790                        pass.set_index_buffer(
1791                            mesh.index_buffer.slice(..),
1792                            wgpu::IndexFormat::Uint32,
1793                        );
1794                        pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1795                    }
1796                }
1797                // Pass 2: advect pass (fullscreen, writes LIC intensity to lic_output_texture).
1798                {
1799                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1800                        label: Some("lic_advect_pass"),
1801                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1802                            view: &slot_hdr.lic_output_view,
1803                            resolve_target: None,
1804                            ops: wgpu::Operations {
1805                                load: wgpu::LoadOp::Clear(wgpu::Color {
1806                                    r: 0.5,
1807                                    g: 0.0,
1808                                    b: 0.0,
1809                                    a: 1.0,
1810                                }),
1811                                store: wgpu::StoreOp::Store,
1812                            },
1813                            depth_slice: None,
1814                        })],
1815                        depth_stencil_attachment: None,
1816                        timestamp_writes: None,
1817                        occlusion_query_set: None,
1818                    });
1819                    pass.set_pipeline(advect_pipeline);
1820                    pass.set_bind_group(0, &slot_hdr.lic_advect_bind_group, &[]);
1821                    pass.draw(0..3, 0..1);
1822                }
1823            }
1824        }
1825
1826        // -----------------------------------------------------------------------
1827        // Outline composite pass (HDR path): blit offscreen outline onto hdr_view.
1828        // Runs after the HDR scene pass (which has depth+stencil) in a separate
1829        // pass with no depth attachment, so the composite pipeline is compatible.
1830        // -----------------------------------------------------------------------
1831        if !slot.outline_object_buffers.is_empty() {
1832            // Prefer the HDR-format pipeline; fall back to LDR single-sample.
1833            let hdr_pipeline = self
1834                .resources
1835                .outline_composite_pipeline_hdr
1836                .as_ref()
1837                .or(self.resources.outline_composite_pipeline_single.as_ref());
1838            if let Some(pipeline) = hdr_pipeline {
1839                let bg = &slot_hdr.outline_composite_bind_group;
1840                let hdr_view = &slot_hdr.hdr_view;
1841                let hdr_depth_view = &slot_hdr.hdr_depth_view;
1842                let mut outline_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1843                    label: Some("hdr_outline_composite_pass"),
1844                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1845                        view: hdr_view,
1846                        resolve_target: None,
1847                        ops: wgpu::Operations {
1848                            load: wgpu::LoadOp::Load,
1849                            store: wgpu::StoreOp::Store,
1850                        },
1851                        depth_slice: None,
1852                    })],
1853                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1854                        view: hdr_depth_view,
1855                        depth_ops: Some(wgpu::Operations {
1856                            load: wgpu::LoadOp::Load,
1857                            store: wgpu::StoreOp::Store,
1858                        }),
1859                        stencil_ops: None,
1860                    }),
1861                    timestamp_writes: None,
1862                    occlusion_query_set: None,
1863                });
1864                outline_pass.set_pipeline(pipeline);
1865                outline_pass.set_bind_group(0, bg, &[]);
1866                outline_pass.draw(0..3, 0..1);
1867            }
1868        }
1869
1870        // Phase 5 : effect throttling. Flag was computed in prepare() so that
1871        // FrameStats reports exactly what fired rather than an approximation.
1872        let throttle_effects = self.degradation_effects_throttled;
1873
1874        // -----------------------------------------------------------------------
1875        // SSAO pass.
1876        // -----------------------------------------------------------------------
1877        if pp.ssao && !throttle_effects {
1878            if let Some(ssao_pipeline) = &self.resources.ssao_pipeline {
1879                {
1880                    let mut ssao_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1881                        label: Some("ssao_pass"),
1882                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1883                            view: &slot_hdr.ssao_view,
1884                            resolve_target: None,
1885                            ops: wgpu::Operations {
1886                                load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
1887                                store: wgpu::StoreOp::Store,
1888                            },
1889                            depth_slice: None,
1890                        })],
1891                        depth_stencil_attachment: None,
1892                        timestamp_writes: None,
1893                        occlusion_query_set: None,
1894                    });
1895                    ssao_pass.set_pipeline(ssao_pipeline);
1896                    ssao_pass.set_bind_group(0, &slot_hdr.ssao_bg, &[]);
1897                    ssao_pass.draw(0..3, 0..1);
1898                }
1899
1900                // SSAO blur pass.
1901                if let Some(ssao_blur_pipeline) = &self.resources.ssao_blur_pipeline {
1902                    let mut ssao_blur_pass =
1903                        encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1904                            label: Some("ssao_blur_pass"),
1905                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1906                                view: &slot_hdr.ssao_blur_view,
1907                                resolve_target: None,
1908                                ops: wgpu::Operations {
1909                                    load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
1910                                    store: wgpu::StoreOp::Store,
1911                                },
1912                                depth_slice: None,
1913                            })],
1914                            depth_stencil_attachment: None,
1915                            timestamp_writes: None,
1916                            occlusion_query_set: None,
1917                        });
1918                    ssao_blur_pass.set_pipeline(ssao_blur_pipeline);
1919                    ssao_blur_pass.set_bind_group(0, &slot_hdr.ssao_blur_bg, &[]);
1920                    ssao_blur_pass.draw(0..3, 0..1);
1921                }
1922            }
1923        }
1924
1925        // -----------------------------------------------------------------------
1926        // Contact shadow pass.
1927        // -----------------------------------------------------------------------
1928        if pp.contact_shadows && !throttle_effects {
1929            if let Some(cs_pipeline) = &self.resources.contact_shadow_pipeline {
1930                let mut cs_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1931                    label: Some("contact_shadow_pass"),
1932                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1933                        view: &slot_hdr.contact_shadow_view,
1934                        resolve_target: None,
1935                        depth_slice: None,
1936                        ops: wgpu::Operations {
1937                            load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
1938                            store: wgpu::StoreOp::Store,
1939                        },
1940                    })],
1941                    depth_stencil_attachment: None,
1942                    timestamp_writes: None,
1943                    occlusion_query_set: None,
1944                });
1945                cs_pass.set_pipeline(cs_pipeline);
1946                cs_pass.set_bind_group(0, &slot_hdr.contact_shadow_bg, &[]);
1947                cs_pass.draw(0..3, 0..1);
1948            }
1949        }
1950
1951        // -----------------------------------------------------------------------
1952        // Bloom passes.
1953        // -----------------------------------------------------------------------
1954        if pp.bloom && !throttle_effects {
1955            // Threshold pass: extract bright pixels into bloom_threshold_texture.
1956            if let Some(bloom_threshold_pipeline) = &self.resources.bloom_threshold_pipeline {
1957                {
1958                    let mut threshold_pass =
1959                        encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1960                            label: Some("bloom_threshold_pass"),
1961                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1962                                view: &slot_hdr.bloom_threshold_view,
1963                                resolve_target: None,
1964                                ops: wgpu::Operations {
1965                                    load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1966                                    store: wgpu::StoreOp::Store,
1967                                },
1968                                depth_slice: None,
1969                            })],
1970                            depth_stencil_attachment: None,
1971                            timestamp_writes: None,
1972                            occlusion_query_set: None,
1973                        });
1974                    threshold_pass.set_pipeline(bloom_threshold_pipeline);
1975                    threshold_pass.set_bind_group(0, &slot_hdr.bloom_threshold_bg, &[]);
1976                    threshold_pass.draw(0..3, 0..1);
1977                }
1978
1979                // 4 ping-pong H+V blur passes for a wide glow.
1980                // Pass 1: threshold -> ping -> pong. Passes 2-4: pong -> ping -> pong.
1981                if let Some(blur_pipeline) = &self.resources.bloom_blur_pipeline {
1982                    let blur_h_bg = &slot_hdr.bloom_blur_h_bg;
1983                    let blur_h_pong_bg = &slot_hdr.bloom_blur_h_pong_bg;
1984                    let blur_v_bg = &slot_hdr.bloom_blur_v_bg;
1985                    let bloom_ping_view = &slot_hdr.bloom_ping_view;
1986                    let bloom_pong_view = &slot_hdr.bloom_pong_view;
1987                    const BLUR_ITERATIONS: usize = 4;
1988                    for i in 0..BLUR_ITERATIONS {
1989                        // H pass: pass 0 reads threshold, subsequent passes read pong.
1990                        let h_bg = if i == 0 { blur_h_bg } else { blur_h_pong_bg };
1991                        {
1992                            let mut h_pass =
1993                                encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1994                                    label: Some("bloom_blur_h_pass"),
1995                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1996                                        view: bloom_ping_view,
1997                                        resolve_target: None,
1998                                        ops: wgpu::Operations {
1999                                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
2000                                            store: wgpu::StoreOp::Store,
2001                                        },
2002                                        depth_slice: None,
2003                                    })],
2004                                    depth_stencil_attachment: None,
2005                                    timestamp_writes: None,
2006                                    occlusion_query_set: None,
2007                                });
2008                            h_pass.set_pipeline(blur_pipeline);
2009                            h_pass.set_bind_group(0, h_bg, &[]);
2010                            h_pass.draw(0..3, 0..1);
2011                        }
2012                        // V pass: ping -> pong.
2013                        {
2014                            let mut v_pass =
2015                                encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2016                                    label: Some("bloom_blur_v_pass"),
2017                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2018                                        view: bloom_pong_view,
2019                                        resolve_target: None,
2020                                        ops: wgpu::Operations {
2021                                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
2022                                            store: wgpu::StoreOp::Store,
2023                                        },
2024                                        depth_slice: None,
2025                                    })],
2026                                    depth_stencil_attachment: None,
2027                                    timestamp_writes: None,
2028                                    occlusion_query_set: None,
2029                                });
2030                            v_pass.set_pipeline(blur_pipeline);
2031                            v_pass.set_bind_group(0, blur_v_bg, &[]);
2032                            v_pass.draw(0..3, 0..1);
2033                        }
2034                    }
2035                }
2036            }
2037        }
2038
2039        // -----------------------------------------------------------------------
2040        // Tone map pass: HDR + bloom + AO -> (fxaa_texture if FXAA) or output_view.
2041        // -----------------------------------------------------------------------
2042        let use_fxaa = pp.fxaa;
2043        if let Some(tone_map_pipeline) = &self.resources.tone_map_pipeline {
2044            let tone_target: &wgpu::TextureView = if use_fxaa {
2045                &slot_hdr.fxaa_view
2046            } else {
2047                output_view
2048            };
2049            let mut tone_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2050                label: Some("tone_map_pass"),
2051                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2052                    view: tone_target,
2053                    resolve_target: None,
2054                    ops: wgpu::Operations {
2055                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
2056                        store: wgpu::StoreOp::Store,
2057                    },
2058                    depth_slice: None,
2059                })],
2060                depth_stencil_attachment: None,
2061                timestamp_writes: None,
2062                occlusion_query_set: None,
2063            });
2064            tone_pass.set_pipeline(tone_map_pipeline);
2065            tone_pass.set_bind_group(0, &slot_hdr.tone_map_bind_group, &[]);
2066            tone_pass.draw(0..3, 0..1);
2067        }
2068
2069        // -----------------------------------------------------------------------
2070        // FXAA pass: fxaa_texture -> output_view (only when FXAA is enabled).
2071        // -----------------------------------------------------------------------
2072        if use_fxaa {
2073            if let Some(fxaa_pipeline) = &self.resources.fxaa_pipeline {
2074                let mut fxaa_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2075                    label: Some("fxaa_pass"),
2076                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2077                        view: output_view,
2078                        resolve_target: None,
2079                        ops: wgpu::Operations {
2080                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
2081                            store: wgpu::StoreOp::Store,
2082                        },
2083                        depth_slice: None,
2084                    })],
2085                    depth_stencil_attachment: None,
2086                    timestamp_writes: None,
2087                    occlusion_query_set: None,
2088                });
2089                fxaa_pass.set_pipeline(fxaa_pipeline);
2090                fxaa_pass.set_bind_group(0, &slot_hdr.fxaa_bind_group, &[]);
2091                fxaa_pass.draw(0..3, 0..1);
2092            }
2093        }
2094
2095        // Grid pass (HDR path): draw the existing analytical grid on the final
2096        // output after tone mapping / FXAA, reusing the scene depth buffer so
2097        // scene geometry still occludes the grid exactly as in the LDR path.
2098        if frame.viewport.show_grid {
2099            let slot = &self.viewport_slots[vp_idx];
2100            let slot_hdr = slot.hdr.as_ref().unwrap();
2101            let grid_bg = &slot.grid_bind_group;
2102            let mut grid_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2103                label: Some("hdr_grid_pass"),
2104                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2105                    view: output_view,
2106                    resolve_target: None,
2107                    ops: wgpu::Operations {
2108                        load: wgpu::LoadOp::Load,
2109                        store: wgpu::StoreOp::Store,
2110                    },
2111                    depth_slice: None,
2112                })],
2113                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2114                    view: &slot_hdr.hdr_depth_view,
2115                    depth_ops: Some(wgpu::Operations {
2116                        load: wgpu::LoadOp::Load,
2117                        store: wgpu::StoreOp::Store,
2118                    }),
2119                    stencil_ops: None,
2120                }),
2121                timestamp_writes: None,
2122                occlusion_query_set: None,
2123            });
2124            grid_pass.set_pipeline(&self.resources.grid_pipeline);
2125            grid_pass.set_bind_group(0, grid_bg, &[]);
2126            grid_pass.draw(0..3, 0..1);
2127        }
2128
2129        // Ground plane pass (HDR path): drawn after grid, before editor overlays.
2130        // Uses the scene depth buffer for correct occlusion against geometry.
2131        if !matches!(
2132            frame.effects.ground_plane.mode,
2133            crate::renderer::types::GroundPlaneMode::None
2134        ) {
2135            let slot = &self.viewport_slots[vp_idx];
2136            let slot_hdr = slot.hdr.as_ref().unwrap();
2137            let mut gp_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2138                label: Some("hdr_ground_plane_pass"),
2139                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2140                    view: output_view,
2141                    resolve_target: None,
2142                    ops: wgpu::Operations {
2143                        load: wgpu::LoadOp::Load,
2144                        store: wgpu::StoreOp::Store,
2145                    },
2146                    depth_slice: None,
2147                })],
2148                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2149                    view: &slot_hdr.hdr_depth_view,
2150                    depth_ops: Some(wgpu::Operations {
2151                        load: wgpu::LoadOp::Load,
2152                        store: wgpu::StoreOp::Store,
2153                    }),
2154                    stencil_ops: None,
2155                }),
2156                timestamp_writes: None,
2157                occlusion_query_set: None,
2158            });
2159            gp_pass.set_pipeline(&self.resources.ground_plane_pipeline);
2160            gp_pass.set_bind_group(0, &self.resources.ground_plane_bind_group, &[]);
2161            gp_pass.draw(0..3, 0..1);
2162        }
2163
2164        // Editor overlay pass (HDR path): draw viewport/editor overlays on the
2165        // final output after tone mapping / FXAA, reusing the scene depth
2166        // buffer so depth-tested helpers still behave correctly.
2167        {
2168            let slot = &self.viewport_slots[vp_idx];
2169            let slot_hdr = slot.hdr.as_ref().unwrap();
2170            let has_editor_overlays = (frame.interaction.gizmo_model.is_some()
2171                && slot.gizmo_index_count > 0)
2172                || !slot.constraint_line_buffers.is_empty()
2173                || !slot.clip_plane_fill_buffers.is_empty()
2174                || !slot.clip_plane_line_buffers.is_empty()
2175                || !slot.xray_object_buffers.is_empty();
2176            if has_editor_overlays {
2177                let camera_bg = &slot.camera_bind_group;
2178                let mut overlay_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2179                    label: Some("hdr_editor_overlay_pass"),
2180                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2181                        view: output_view,
2182                        resolve_target: None,
2183                        ops: wgpu::Operations {
2184                            load: wgpu::LoadOp::Load,
2185                            store: wgpu::StoreOp::Store,
2186                        },
2187                        depth_slice: None,
2188                    })],
2189                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2190                        view: &slot_hdr.hdr_depth_view,
2191                        depth_ops: Some(wgpu::Operations {
2192                            load: wgpu::LoadOp::Load,
2193                            store: wgpu::StoreOp::Discard,
2194                        }),
2195                        stencil_ops: None,
2196                    }),
2197                    timestamp_writes: None,
2198                    occlusion_query_set: None,
2199                });
2200
2201                if frame.interaction.gizmo_model.is_some() && slot.gizmo_index_count > 0 {
2202                    overlay_pass.set_pipeline(&self.resources.gizmo_pipeline);
2203                    overlay_pass.set_bind_group(0, camera_bg, &[]);
2204                    overlay_pass.set_bind_group(1, &slot.gizmo_bind_group, &[]);
2205                    overlay_pass.set_vertex_buffer(0, slot.gizmo_vertex_buffer.slice(..));
2206                    overlay_pass.set_index_buffer(
2207                        slot.gizmo_index_buffer.slice(..),
2208                        wgpu::IndexFormat::Uint32,
2209                    );
2210                    overlay_pass.draw_indexed(0..slot.gizmo_index_count, 0, 0..1);
2211                }
2212
2213                if !slot.constraint_line_buffers.is_empty() {
2214                    overlay_pass.set_pipeline(&self.resources.overlay_line_pipeline);
2215                    overlay_pass.set_bind_group(0, camera_bg, &[]);
2216                    for (vbuf, ibuf, index_count, _ubuf, bg) in &slot.constraint_line_buffers {
2217                        overlay_pass.set_bind_group(1, bg, &[]);
2218                        overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
2219                        overlay_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
2220                        overlay_pass.draw_indexed(0..*index_count, 0, 0..1);
2221                    }
2222                }
2223
2224                if !slot.clip_plane_fill_buffers.is_empty() {
2225                    overlay_pass.set_pipeline(&self.resources.overlay_pipeline);
2226                    overlay_pass.set_bind_group(0, camera_bg, &[]);
2227                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.clip_plane_fill_buffers {
2228                        overlay_pass.set_bind_group(1, bg, &[]);
2229                        overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
2230                        overlay_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
2231                        overlay_pass.draw_indexed(0..*idx_count, 0, 0..1);
2232                    }
2233                }
2234
2235                if !slot.clip_plane_line_buffers.is_empty() {
2236                    overlay_pass.set_pipeline(&self.resources.overlay_line_pipeline);
2237                    overlay_pass.set_bind_group(0, camera_bg, &[]);
2238                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.clip_plane_line_buffers {
2239                        overlay_pass.set_bind_group(1, bg, &[]);
2240                        overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
2241                        overlay_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
2242                        overlay_pass.draw_indexed(0..*idx_count, 0, 0..1);
2243                    }
2244                }
2245
2246                if !slot.xray_object_buffers.is_empty() {
2247                    overlay_pass.set_pipeline(&self.resources.xray_pipeline);
2248                    overlay_pass.set_bind_group(0, camera_bg, &[]);
2249                    for (mesh_id, _buf, bg) in &slot.xray_object_buffers {
2250                        let Some(mesh) = self
2251                            .resources
2252                            .mesh_store
2253                            .get(*mesh_id)
2254                        else {
2255                            continue;
2256                        };
2257                        overlay_pass.set_bind_group(1, bg, &[]);
2258                        overlay_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
2259                        overlay_pass.set_index_buffer(
2260                            mesh.index_buffer.slice(..),
2261                            wgpu::IndexFormat::Uint32,
2262                        );
2263                        overlay_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
2264                    }
2265                }
2266            }
2267        }
2268
2269        // Axes indicator pass (HDR path): draw in screen space on the final
2270        // output after tone mapping / FXAA so it stays visible in PBR mode.
2271        if frame.viewport.show_axes_indicator {
2272            let slot = &self.viewport_slots[vp_idx];
2273            if slot.axes_vertex_count > 0 {
2274                let slot_hdr = slot.hdr.as_ref().unwrap();
2275                let mut axes_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2276                    label: Some("hdr_axes_pass"),
2277                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2278                        view: output_view,
2279                        resolve_target: None,
2280                        ops: wgpu::Operations {
2281                            load: wgpu::LoadOp::Load,
2282                            store: wgpu::StoreOp::Store,
2283                        },
2284                        depth_slice: None,
2285                    })],
2286                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2287                        view: &slot_hdr.hdr_depth_view,
2288                        depth_ops: Some(wgpu::Operations {
2289                            load: wgpu::LoadOp::Load,
2290                            store: wgpu::StoreOp::Discard,
2291                        }),
2292                        stencil_ops: None,
2293                    }),
2294                    timestamp_writes: None,
2295                    occlusion_query_set: None,
2296                });
2297                axes_pass.set_pipeline(&self.resources.axes_pipeline);
2298                axes_pass.set_vertex_buffer(0, slot.axes_vertex_buffer.slice(..));
2299                axes_pass.draw(0..slot.axes_vertex_count, 0..1);
2300            }
2301        }
2302
2303        // Phase 10B / Phase 12 : screen-space image overlay pass (HDR path).
2304        // Drawn after axes so overlays are always on top of everything.
2305        // Regular items use depth_compare: Always; depth-composite items use LessEqual.
2306        if !self.screen_image_gpu_data.is_empty() {
2307            if let Some(overlay_pipeline) = &self.resources.screen_image_pipeline {
2308                let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
2309                let dc_pipeline = self.resources.screen_image_dc_pipeline.as_ref();
2310                let mut img_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2311                    label: Some("screen_image_pass"),
2312                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2313                        view: output_view,
2314                        resolve_target: None,
2315                        ops: wgpu::Operations {
2316                            load: wgpu::LoadOp::Load,
2317                            store: wgpu::StoreOp::Store,
2318                        },
2319                        depth_slice: None,
2320                    })],
2321                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2322                        view: &slot_hdr.hdr_depth_view,
2323                        depth_ops: Some(wgpu::Operations {
2324                            load: wgpu::LoadOp::Load,
2325                            store: wgpu::StoreOp::Discard,
2326                        }),
2327                        stencil_ops: None,
2328                    }),
2329                    timestamp_writes: None,
2330                    occlusion_query_set: None,
2331                });
2332                for gpu in &self.screen_image_gpu_data {
2333                    if let (Some(dc_bg), Some(dc_pipe)) = (&gpu.depth_bind_group, dc_pipeline) {
2334                        img_pass.set_pipeline(dc_pipe);
2335                        img_pass.set_bind_group(0, dc_bg, &[]);
2336                    } else {
2337                        img_pass.set_pipeline(overlay_pipeline);
2338                        img_pass.set_bind_group(0, &gpu.bind_group, &[]);
2339                    }
2340                    img_pass.draw(0..6, 0..1);
2341                }
2342            }
2343        }
2344
2345        // Overlay labels, scalar bars, rulers, and overlay images (HDR path): drawn last.
2346        let has_overlay = self.label_gpu_data.is_some()
2347            || self.scalar_bar_gpu_data.is_some()
2348            || self.ruler_gpu_data.is_some()
2349            || self.loading_bar_gpu_data.is_some()
2350            || !self.overlay_image_gpu_data.is_empty();
2351        if has_overlay {
2352            let hdr_depth_view =
2353                &self.viewport_slots[vp_idx].hdr.as_ref().unwrap().hdr_depth_view;
2354            let mut overlay_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
2355                label: Some("overlay_pass"),
2356                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
2357                    view: output_view,
2358                    resolve_target: None,
2359                    ops: wgpu::Operations {
2360                        load: wgpu::LoadOp::Load,
2361                        store: wgpu::StoreOp::Store,
2362                    },
2363                    depth_slice: None,
2364                })],
2365                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
2366                    view: hdr_depth_view,
2367                    depth_ops: Some(wgpu::Operations {
2368                        load: wgpu::LoadOp::Load,
2369                        store: wgpu::StoreOp::Discard,
2370                    }),
2371                    stencil_ops: None,
2372                }),
2373                timestamp_writes: None,
2374                occlusion_query_set: None,
2375            });
2376            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
2377                overlay_pass.set_pipeline(pipeline);
2378                if let Some(ref ld) = self.label_gpu_data {
2379                    overlay_pass.set_bind_group(0, &ld.bind_group, &[]);
2380                    overlay_pass.set_vertex_buffer(0, ld.vertex_buf.slice(..));
2381                    overlay_pass.draw(0..ld.vertex_count, 0..1);
2382                }
2383                if let Some(ref sb) = self.scalar_bar_gpu_data {
2384                    overlay_pass.set_bind_group(0, &sb.bind_group, &[]);
2385                    overlay_pass.set_vertex_buffer(0, sb.vertex_buf.slice(..));
2386                    overlay_pass.draw(0..sb.vertex_count, 0..1);
2387                }
2388                if let Some(ref rd) = self.ruler_gpu_data {
2389                    overlay_pass.set_bind_group(0, &rd.bind_group, &[]);
2390                    overlay_pass.set_vertex_buffer(0, rd.vertex_buf.slice(..));
2391                    overlay_pass.draw(0..rd.vertex_count, 0..1);
2392                }
2393                if let Some(ref lb) = self.loading_bar_gpu_data {
2394                    overlay_pass.set_bind_group(0, &lb.bind_group, &[]);
2395                    overlay_pass.set_vertex_buffer(0, lb.vertex_buf.slice(..));
2396                    overlay_pass.draw(0..lb.vertex_count, 0..1);
2397                }
2398            }
2399            // Phase 7 : overlay images drawn last inside the overlay pass.
2400            if !self.overlay_image_gpu_data.is_empty() {
2401                if let Some(pipeline) = &self.resources.screen_image_pipeline {
2402                    overlay_pass.set_pipeline(pipeline);
2403                    for gpu in &self.overlay_image_gpu_data {
2404                        overlay_pass.set_bind_group(0, &gpu.bind_group, &[]);
2405                        overlay_pass.draw(0..6, 0..1);
2406                    }
2407                }
2408            }
2409        }
2410
2411        // Phase 4 : resolve timestamp queries -> staging buffer (HDR path).
2412        if let (Some(qs), Some(res_buf), Some(stg_buf)) = (
2413            self.ts_query_set.as_ref(),
2414            self.ts_resolve_buf.as_ref(),
2415            self.ts_staging_buf.as_ref(),
2416        ) {
2417            encoder.resolve_query_set(qs, 0..2, res_buf, 0);
2418            encoder.copy_buffer_to_buffer(res_buf, 0, stg_buf, 0, 16);
2419            self.ts_needs_readback = true;
2420        }
2421
2422        encoder.finish()
2423    }
2424
2425    /// Render a frame to an offscreen texture and return raw RGBA bytes.
2426    ///
2427    /// Creates a temporary [`wgpu::Texture`] render target of the given dimensions,
2428    /// runs all render passes (shadow, scene, post-processing) into it via
2429    /// [`render()`](Self::render), then copies the result back to CPU memory.
2430    ///
2431    /// No OS window or [`wgpu::Surface`] is required. The caller is responsible for
2432    /// initialising the wgpu adapter with `compatible_surface: None` and for
2433    /// constructing a valid [`FrameData`] (including `viewport_size` matching
2434    /// `width`/`height`).
2435    ///
2436    /// Returns `width * height * 4` bytes in RGBA8 layout. The caller encodes to
2437    /// PNG/EXR independently : no image codec dependency in this crate.
2438    pub fn render_offscreen(
2439        &mut self,
2440        device: &wgpu::Device,
2441        queue: &wgpu::Queue,
2442        frame: &FrameData,
2443        width: u32,
2444        height: u32,
2445    ) -> Vec<u8> {
2446        // 1. Create offscreen texture with RENDER_ATTACHMENT | COPY_SRC usage.
2447        let target_format = self.resources.target_format;
2448        let offscreen_texture = device.create_texture(&wgpu::TextureDescriptor {
2449            label: Some("offscreen_target"),
2450            size: wgpu::Extent3d {
2451                width: width.max(1),
2452                height: height.max(1),
2453                depth_or_array_layers: 1,
2454            },
2455            mip_level_count: 1,
2456            sample_count: 1,
2457            dimension: wgpu::TextureDimension::D2,
2458            format: target_format,
2459            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
2460            view_formats: &[],
2461        });
2462
2463        // 2. Create a texture view for rendering into.
2464        let output_view = offscreen_texture.create_view(&wgpu::TextureViewDescriptor::default());
2465
2466        // 3. render() calls ensure_viewport_hdr which provides the depth-stencil buffer
2467        //    for both LDR and HDR paths, so no separate ensure_outline_target is needed.
2468
2469        // 4. Render the scene into the offscreen texture.
2470        //    The caller must set `frame.camera.viewport_size` to `[width as f32, height as f32]`
2471        //    and `frame.camera.render_camera.aspect` to `width as f32 / height as f32`
2472        //    for correct HDR target allocation and scissor rects.
2473        let cmd_buf = self.render(device, queue, &output_view, frame);
2474        queue.submit(std::iter::once(cmd_buf));
2475
2476        // 5. Copy texture -> staging buffer (wgpu requires row alignment to 256 bytes).
2477        let bytes_per_pixel = 4u32;
2478        let unpadded_row = width * bytes_per_pixel;
2479        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
2480        let padded_row = (unpadded_row + align - 1) & !(align - 1);
2481        let buffer_size = (padded_row * height.max(1)) as u64;
2482
2483        let staging_buf = device.create_buffer(&wgpu::BufferDescriptor {
2484            label: Some("offscreen_staging"),
2485            size: buffer_size,
2486            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2487            mapped_at_creation: false,
2488        });
2489
2490        let mut copy_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
2491            label: Some("offscreen_copy_encoder"),
2492        });
2493        copy_encoder.copy_texture_to_buffer(
2494            wgpu::TexelCopyTextureInfo {
2495                texture: &offscreen_texture,
2496                mip_level: 0,
2497                origin: wgpu::Origin3d::ZERO,
2498                aspect: wgpu::TextureAspect::All,
2499            },
2500            wgpu::TexelCopyBufferInfo {
2501                buffer: &staging_buf,
2502                layout: wgpu::TexelCopyBufferLayout {
2503                    offset: 0,
2504                    bytes_per_row: Some(padded_row),
2505                    rows_per_image: Some(height.max(1)),
2506                },
2507            },
2508            wgpu::Extent3d {
2509                width: width.max(1),
2510                height: height.max(1),
2511                depth_or_array_layers: 1,
2512            },
2513        );
2514        queue.submit(std::iter::once(copy_encoder.finish()));
2515
2516        // 6. Map buffer and extract tightly-packed RGBA pixels.
2517        let (tx, rx) = std::sync::mpsc::channel();
2518        staging_buf
2519            .slice(..)
2520            .map_async(wgpu::MapMode::Read, move |result| {
2521                let _ = tx.send(result);
2522            });
2523        device
2524            .poll(wgpu::PollType::Wait {
2525                submission_index: None,
2526                timeout: Some(std::time::Duration::from_secs(5)),
2527            })
2528            .unwrap();
2529        let _ = rx.recv().unwrap_or(Err(wgpu::BufferAsyncError));
2530
2531        let mut pixels: Vec<u8> = Vec::with_capacity((width * height * 4) as usize);
2532        {
2533            let mapped = staging_buf.slice(..).get_mapped_range();
2534            let data: &[u8] = &mapped;
2535            if padded_row == unpadded_row {
2536                // No padding : copy entire slice directly.
2537                pixels.extend_from_slice(data);
2538            } else {
2539                // Strip row padding.
2540                for row in 0..height as usize {
2541                    let start = row * padded_row as usize;
2542                    let end = start + unpadded_row as usize;
2543                    pixels.extend_from_slice(&data[start..end]);
2544                }
2545            }
2546        }
2547        staging_buf.unmap();
2548
2549        // 7. Swizzle BGRA -> RGBA if the format stores bytes in BGRA order.
2550        let is_bgra = matches!(
2551            target_format,
2552            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
2553        );
2554        if is_bgra {
2555            for pixel in pixels.chunks_exact_mut(4) {
2556                pixel.swap(0, 2); // B ↔ R
2557            }
2558        }
2559
2560        pixels
2561    }
2562}