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