Skip to main content

viewport_lib/renderer/
render.rs

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