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