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