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