Skip to main content

viewport_lib/renderer/
render.rs

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