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                                )
637                            });
638                            if frame.viewport.wireframe_mode {
639                                render_pass.set_pipeline(wf_pl);
640                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
641                                render_pass.set_index_buffer(
642                                    mesh.edge_index_buffer.slice(..),
643                                    wgpu::IndexFormat::Uint32,
644                                );
645                                render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
646                            } else if is_face_attr {
647                                if let Some(ref fvb) = mesh.face_vertex_buffer {
648                                    let pl = if item.material.opacity < 1.0 {
649                                        trans_pl
650                                    } else {
651                                        solid_pl
652                                    };
653                                    render_pass.set_pipeline(pl);
654                                    render_pass.set_vertex_buffer(0, fvb.slice(..));
655                                    render_pass.draw(0..mesh.index_count, 0..1);
656                                }
657                            } else if item.material.opacity < 1.0 {
658                                render_pass.set_pipeline(trans_pl);
659                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
660                                render_pass.set_index_buffer(
661                                    mesh.index_buffer.slice(..),
662                                    wgpu::IndexFormat::Uint32,
663                                );
664                                render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
665                            } else {
666                                render_pass.set_pipeline(solid_pl);
667                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
668                                render_pass.set_index_buffer(
669                                    mesh.index_buffer.slice(..),
670                                    wgpu::IndexFormat::Uint32,
671                                );
672                                render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
673                            }
674                        };
675
676                    // NOTE: only opaque items are drawn here. Transparent items are
677                    // routed to the OIT pass below.
678                    let _ = &transparent; // suppress unused warning
679                    if let (
680                        Some(hdr_solid),
681                        Some(hdr_solid_two_sided),
682                        Some(hdr_trans),
683                        Some(hdr_wf),
684                    ) = (
685                        &resources.hdr_solid_pipeline,
686                        &resources.hdr_solid_two_sided_pipeline,
687                        &resources.hdr_transparent_pipeline,
688                        &resources.hdr_wireframe_pipeline,
689                    ) {
690                        for item in &opaque {
691                            let solid_pl = if item.material.is_two_sided() {
692                                hdr_solid_two_sided
693                            } else {
694                                hdr_solid
695                            };
696                            draw_item_hdr(&mut render_pass, item, solid_pl, hdr_trans, hdr_wf);
697                        }
698                    }
699                }
700            }
701
702            // Cap fill pass (HDR path : section view cross-section fill).
703            if !slot.cap_buffers.is_empty() {
704                if let Some(ref hdr_overlay) = resources.hdr_overlay_pipeline {
705                    render_pass.set_pipeline(hdr_overlay);
706                    render_pass.set_bind_group(0, camera_bg, &[]);
707                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.cap_buffers {
708                        render_pass.set_bind_group(1, bg, &[]);
709                        render_pass.set_vertex_buffer(0, vbuf.slice(..));
710                        render_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
711                        render_pass.draw_indexed(0..*idx_count, 0, 0..1);
712                    }
713                }
714            }
715
716            // SciVis Phase B+D+M8+M: point cloud, glyph, polyline, volume, streamtube (HDR path).
717            emit_scivis_draw_calls!(
718                &self.resources,
719                &mut render_pass,
720                &self.point_cloud_gpu_data,
721                &self.glyph_gpu_data,
722                &self.polyline_gpu_data,
723                &self.volume_gpu_data,
724                &self.streamtube_gpu_data,
725                camera_bg
726            );
727
728            // Draw skybox last among opaques : only uncovered sky pixels pass depth == 1.0.
729            if show_skybox {
730                render_pass.set_bind_group(0, camera_bg, &[]);
731                render_pass.set_pipeline(&resources.skybox_pipeline);
732                render_pass.draw(0..3, 0..1);
733            }
734        }
735
736        // -----------------------------------------------------------------------
737        // SSAA resolve pass: downsample supersampled scene -> hdr_texture.
738        // Only runs when ssaa_factor > 1 and the resolve pipeline is available.
739        // -----------------------------------------------------------------------
740        if ssaa_factor > 1 {
741            let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
742            if let (Some(pipeline), Some(bg)) = (
743                &self.resources.ssaa_resolve_pipeline,
744                &slot_hdr.ssaa_resolve_bind_group,
745            ) {
746                let mut resolve_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
747                    label: Some("ssaa_resolve_pass"),
748                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
749                        view: &slot_hdr.hdr_view,
750                        resolve_target: None,
751                        ops: wgpu::Operations {
752                            load: wgpu::LoadOp::Load,
753                            store: wgpu::StoreOp::Store,
754                        },
755                        depth_slice: None,
756                    })],
757                    depth_stencil_attachment: None,
758                    timestamp_writes: None,
759                    occlusion_query_set: None,
760                });
761                resolve_pass.set_pipeline(pipeline);
762                resolve_pass.set_bind_group(0, bg, &[]);
763                resolve_pass.draw(0..3, 0..1);
764            }
765        }
766
767        // -----------------------------------------------------------------------
768        // OIT pass: render transparent items into accum + reveal textures.
769        // Completely skipped when no transparent items exist (zero overhead).
770        // -----------------------------------------------------------------------
771        let has_transparent = if self.use_instancing && !self.instanced_batches.is_empty() {
772            self.instanced_batches.iter().any(|b| b.is_transparent)
773        } else {
774            scene_items
775                .iter()
776                .any(|i| i.visible && i.material.opacity < 1.0)
777        };
778
779        if has_transparent {
780            // OIT targets already allocated in the pre-pass above.
781            if let (Some(accum_view), Some(reveal_view)) = (
782                slot_hdr.oit_accum_view.as_ref(),
783                slot_hdr.oit_reveal_view.as_ref(),
784            ) {
785                let hdr_depth_view = &slot_hdr.hdr_depth_view;
786                // Clear accum to (0,0,0,0), reveal to 1.0 (no contribution yet).
787                let mut oit_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
788                    label: Some("oit_pass"),
789                    color_attachments: &[
790                        Some(wgpu::RenderPassColorAttachment {
791                            view: accum_view,
792                            resolve_target: None,
793                            ops: wgpu::Operations {
794                                load: wgpu::LoadOp::Clear(wgpu::Color {
795                                    r: 0.0,
796                                    g: 0.0,
797                                    b: 0.0,
798                                    a: 0.0,
799                                }),
800                                store: wgpu::StoreOp::Store,
801                            },
802                            depth_slice: None,
803                        }),
804                        Some(wgpu::RenderPassColorAttachment {
805                            view: reveal_view,
806                            resolve_target: None,
807                            ops: wgpu::Operations {
808                                load: wgpu::LoadOp::Clear(wgpu::Color {
809                                    r: 1.0,
810                                    g: 1.0,
811                                    b: 1.0,
812                                    a: 1.0,
813                                }),
814                                store: wgpu::StoreOp::Store,
815                            },
816                            depth_slice: None,
817                        }),
818                    ],
819                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
820                        view: hdr_depth_view,
821                        depth_ops: Some(wgpu::Operations {
822                            load: wgpu::LoadOp::Load, // reuse opaque depth
823                            store: wgpu::StoreOp::Store,
824                        }),
825                        stencil_ops: None,
826                    }),
827                    timestamp_writes: None,
828                    occlusion_query_set: None,
829                });
830
831                oit_pass.set_bind_group(0, camera_bg, &[]);
832
833                if self.use_instancing && !self.instanced_batches.is_empty() {
834                    if let Some(ref pipeline) = self.resources.oit_instanced_pipeline {
835                        oit_pass.set_pipeline(pipeline);
836                        for batch in &self.instanced_batches {
837                            if !batch.is_transparent {
838                                continue;
839                            }
840                            let Some(mesh) = self
841                                .resources
842                                .mesh_store
843                                .get(batch.mesh_id)
844                            else {
845                                continue;
846                            };
847                            let mat_key = (
848                                batch.texture_id.unwrap_or(u64::MAX),
849                                batch.normal_map_id.unwrap_or(u64::MAX),
850                                batch.ao_map_id.unwrap_or(u64::MAX),
851                            );
852                            let Some(inst_tex_bg) =
853                                self.resources.instance_bind_groups.get(&mat_key)
854                            else {
855                                continue;
856                            };
857                            oit_pass.set_bind_group(1, inst_tex_bg, &[]);
858                            oit_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
859                            oit_pass.set_index_buffer(
860                                mesh.index_buffer.slice(..),
861                                wgpu::IndexFormat::Uint32,
862                            );
863                            oit_pass.draw_indexed(
864                                0..mesh.index_count,
865                                0,
866                                batch.instance_offset..batch.instance_offset + batch.instance_count,
867                            );
868                        }
869                    }
870                } else if let Some(ref pipeline) = self.resources.oit_pipeline {
871                    oit_pass.set_pipeline(pipeline);
872                    for item in scene_items {
873                        if !item.visible || item.material.opacity >= 1.0 {
874                            continue;
875                        }
876                        let Some(mesh) = self
877                            .resources
878                            .mesh_store
879                            .get(item.mesh_id)
880                        else {
881                            continue;
882                        };
883                        oit_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
884                        oit_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
885                        oit_pass.set_index_buffer(
886                            mesh.index_buffer.slice(..),
887                            wgpu::IndexFormat::Uint32,
888                        );
889                        oit_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
890                    }
891                }
892            }
893        }
894
895        // -----------------------------------------------------------------------
896        // OIT composite pass: blend accum/reveal into HDR buffer.
897        // Only executes when transparent items were present.
898        // -----------------------------------------------------------------------
899        if has_transparent {
900            if let (Some(pipeline), Some(bg)) = (
901                self.resources.oit_composite_pipeline.as_ref(),
902                slot_hdr.oit_composite_bind_group.as_ref(),
903            ) {
904                let hdr_view = &slot_hdr.hdr_view;
905                let mut composite_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
906                    label: Some("oit_composite_pass"),
907                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
908                        view: hdr_view,
909                        resolve_target: None,
910                        ops: wgpu::Operations {
911                            load: wgpu::LoadOp::Load,
912                            store: wgpu::StoreOp::Store,
913                        },
914                        depth_slice: None,
915                    })],
916                    depth_stencil_attachment: None,
917                    timestamp_writes: None,
918                    occlusion_query_set: None,
919                });
920                composite_pass.set_pipeline(pipeline);
921                composite_pass.set_bind_group(0, bg, &[]);
922                composite_pass.draw(0..3, 0..1);
923            }
924        }
925
926        // -----------------------------------------------------------------------
927        // Outline composite pass (HDR path): blit offscreen outline onto hdr_view.
928        // Runs after the HDR scene pass (which has depth+stencil) in a separate
929        // pass with no depth attachment, so the composite pipeline is compatible.
930        // -----------------------------------------------------------------------
931        if !slot.outline_object_buffers.is_empty() {
932            // Prefer the HDR-format pipeline; fall back to LDR single-sample.
933            let hdr_pipeline = self
934                .resources
935                .outline_composite_pipeline_hdr
936                .as_ref()
937                .or(self.resources.outline_composite_pipeline_single.as_ref());
938            if let Some(pipeline) = hdr_pipeline {
939                let bg = &slot_hdr.outline_composite_bind_group;
940                let hdr_view = &slot_hdr.hdr_view;
941                let hdr_depth_view = &slot_hdr.hdr_depth_view;
942                let mut outline_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
943                    label: Some("hdr_outline_composite_pass"),
944                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
945                        view: hdr_view,
946                        resolve_target: None,
947                        ops: wgpu::Operations {
948                            load: wgpu::LoadOp::Load,
949                            store: wgpu::StoreOp::Store,
950                        },
951                        depth_slice: None,
952                    })],
953                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
954                        view: hdr_depth_view,
955                        depth_ops: Some(wgpu::Operations {
956                            load: wgpu::LoadOp::Load,
957                            store: wgpu::StoreOp::Store,
958                        }),
959                        stencil_ops: None,
960                    }),
961                    timestamp_writes: None,
962                    occlusion_query_set: None,
963                });
964                outline_pass.set_pipeline(pipeline);
965                outline_pass.set_bind_group(0, bg, &[]);
966                outline_pass.draw(0..3, 0..1);
967            }
968        }
969
970        // -----------------------------------------------------------------------
971        // SSAO pass.
972        // -----------------------------------------------------------------------
973        if pp.ssao {
974            if let Some(ssao_pipeline) = &self.resources.ssao_pipeline {
975                {
976                    let mut ssao_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
977                        label: Some("ssao_pass"),
978                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
979                            view: &slot_hdr.ssao_view,
980                            resolve_target: None,
981                            ops: wgpu::Operations {
982                                load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
983                                store: wgpu::StoreOp::Store,
984                            },
985                            depth_slice: None,
986                        })],
987                        depth_stencil_attachment: None,
988                        timestamp_writes: None,
989                        occlusion_query_set: None,
990                    });
991                    ssao_pass.set_pipeline(ssao_pipeline);
992                    ssao_pass.set_bind_group(0, &slot_hdr.ssao_bg, &[]);
993                    ssao_pass.draw(0..3, 0..1);
994                }
995
996                // SSAO blur pass.
997                if let Some(ssao_blur_pipeline) = &self.resources.ssao_blur_pipeline {
998                    let mut ssao_blur_pass =
999                        encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1000                            label: Some("ssao_blur_pass"),
1001                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1002                                view: &slot_hdr.ssao_blur_view,
1003                                resolve_target: None,
1004                                ops: wgpu::Operations {
1005                                    load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
1006                                    store: wgpu::StoreOp::Store,
1007                                },
1008                                depth_slice: None,
1009                            })],
1010                            depth_stencil_attachment: None,
1011                            timestamp_writes: None,
1012                            occlusion_query_set: None,
1013                        });
1014                    ssao_blur_pass.set_pipeline(ssao_blur_pipeline);
1015                    ssao_blur_pass.set_bind_group(0, &slot_hdr.ssao_blur_bg, &[]);
1016                    ssao_blur_pass.draw(0..3, 0..1);
1017                }
1018            }
1019        }
1020
1021        // -----------------------------------------------------------------------
1022        // Contact shadow pass.
1023        // -----------------------------------------------------------------------
1024        if pp.contact_shadows {
1025            if let Some(cs_pipeline) = &self.resources.contact_shadow_pipeline {
1026                let mut cs_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1027                    label: Some("contact_shadow_pass"),
1028                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1029                        view: &slot_hdr.contact_shadow_view,
1030                        resolve_target: None,
1031                        depth_slice: None,
1032                        ops: wgpu::Operations {
1033                            load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
1034                            store: wgpu::StoreOp::Store,
1035                        },
1036                    })],
1037                    depth_stencil_attachment: None,
1038                    timestamp_writes: None,
1039                    occlusion_query_set: None,
1040                });
1041                cs_pass.set_pipeline(cs_pipeline);
1042                cs_pass.set_bind_group(0, &slot_hdr.contact_shadow_bg, &[]);
1043                cs_pass.draw(0..3, 0..1);
1044            }
1045        }
1046
1047        // -----------------------------------------------------------------------
1048        // Bloom passes.
1049        // -----------------------------------------------------------------------
1050        if pp.bloom {
1051            // Threshold pass: extract bright pixels into bloom_threshold_texture.
1052            if let Some(bloom_threshold_pipeline) = &self.resources.bloom_threshold_pipeline {
1053                {
1054                    let mut threshold_pass =
1055                        encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1056                            label: Some("bloom_threshold_pass"),
1057                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1058                                view: &slot_hdr.bloom_threshold_view,
1059                                resolve_target: None,
1060                                ops: wgpu::Operations {
1061                                    load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1062                                    store: wgpu::StoreOp::Store,
1063                                },
1064                                depth_slice: None,
1065                            })],
1066                            depth_stencil_attachment: None,
1067                            timestamp_writes: None,
1068                            occlusion_query_set: None,
1069                        });
1070                    threshold_pass.set_pipeline(bloom_threshold_pipeline);
1071                    threshold_pass.set_bind_group(0, &slot_hdr.bloom_threshold_bg, &[]);
1072                    threshold_pass.draw(0..3, 0..1);
1073                }
1074
1075                // 4 ping-pong H+V blur passes for a wide glow.
1076                // Pass 1: threshold -> ping -> pong. Passes 2-4: pong -> ping -> pong.
1077                if let Some(blur_pipeline) = &self.resources.bloom_blur_pipeline {
1078                    let blur_h_bg = &slot_hdr.bloom_blur_h_bg;
1079                    let blur_h_pong_bg = &slot_hdr.bloom_blur_h_pong_bg;
1080                    let blur_v_bg = &slot_hdr.bloom_blur_v_bg;
1081                    let bloom_ping_view = &slot_hdr.bloom_ping_view;
1082                    let bloom_pong_view = &slot_hdr.bloom_pong_view;
1083                    const BLUR_ITERATIONS: usize = 4;
1084                    for i in 0..BLUR_ITERATIONS {
1085                        // H pass: pass 0 reads threshold, subsequent passes read pong.
1086                        let h_bg = if i == 0 { blur_h_bg } else { blur_h_pong_bg };
1087                        {
1088                            let mut h_pass =
1089                                encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1090                                    label: Some("bloom_blur_h_pass"),
1091                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1092                                        view: bloom_ping_view,
1093                                        resolve_target: None,
1094                                        ops: wgpu::Operations {
1095                                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1096                                            store: wgpu::StoreOp::Store,
1097                                        },
1098                                        depth_slice: None,
1099                                    })],
1100                                    depth_stencil_attachment: None,
1101                                    timestamp_writes: None,
1102                                    occlusion_query_set: None,
1103                                });
1104                            h_pass.set_pipeline(blur_pipeline);
1105                            h_pass.set_bind_group(0, h_bg, &[]);
1106                            h_pass.draw(0..3, 0..1);
1107                        }
1108                        // V pass: ping -> pong.
1109                        {
1110                            let mut v_pass =
1111                                encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1112                                    label: Some("bloom_blur_v_pass"),
1113                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1114                                        view: bloom_pong_view,
1115                                        resolve_target: None,
1116                                        ops: wgpu::Operations {
1117                                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1118                                            store: wgpu::StoreOp::Store,
1119                                        },
1120                                        depth_slice: None,
1121                                    })],
1122                                    depth_stencil_attachment: None,
1123                                    timestamp_writes: None,
1124                                    occlusion_query_set: None,
1125                                });
1126                            v_pass.set_pipeline(blur_pipeline);
1127                            v_pass.set_bind_group(0, blur_v_bg, &[]);
1128                            v_pass.draw(0..3, 0..1);
1129                        }
1130                    }
1131                }
1132            }
1133        }
1134
1135        // -----------------------------------------------------------------------
1136        // Tone map pass: HDR + bloom + AO -> (fxaa_texture if FXAA) or output_view.
1137        // -----------------------------------------------------------------------
1138        let use_fxaa = pp.fxaa;
1139        if let Some(tone_map_pipeline) = &self.resources.tone_map_pipeline {
1140            let tone_target: &wgpu::TextureView = if use_fxaa {
1141                &slot_hdr.fxaa_view
1142            } else {
1143                output_view
1144            };
1145            let mut tone_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1146                label: Some("tone_map_pass"),
1147                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1148                    view: tone_target,
1149                    resolve_target: None,
1150                    ops: wgpu::Operations {
1151                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1152                        store: wgpu::StoreOp::Store,
1153                    },
1154                    depth_slice: None,
1155                })],
1156                depth_stencil_attachment: None,
1157                timestamp_writes: None,
1158                occlusion_query_set: None,
1159            });
1160            tone_pass.set_pipeline(tone_map_pipeline);
1161            tone_pass.set_bind_group(0, &slot_hdr.tone_map_bind_group, &[]);
1162            tone_pass.draw(0..3, 0..1);
1163        }
1164
1165        // -----------------------------------------------------------------------
1166        // FXAA pass: fxaa_texture -> output_view (only when FXAA is enabled).
1167        // -----------------------------------------------------------------------
1168        if use_fxaa {
1169            if let Some(fxaa_pipeline) = &self.resources.fxaa_pipeline {
1170                let mut fxaa_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1171                    label: Some("fxaa_pass"),
1172                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1173                        view: output_view,
1174                        resolve_target: None,
1175                        ops: wgpu::Operations {
1176                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1177                            store: wgpu::StoreOp::Store,
1178                        },
1179                        depth_slice: None,
1180                    })],
1181                    depth_stencil_attachment: None,
1182                    timestamp_writes: None,
1183                    occlusion_query_set: None,
1184                });
1185                fxaa_pass.set_pipeline(fxaa_pipeline);
1186                fxaa_pass.set_bind_group(0, &slot_hdr.fxaa_bind_group, &[]);
1187                fxaa_pass.draw(0..3, 0..1);
1188            }
1189        }
1190
1191        // Grid pass (HDR path): draw the existing analytical grid on the final
1192        // output after tone mapping / FXAA, reusing the scene depth buffer so
1193        // scene geometry still occludes the grid exactly as in the LDR path.
1194        if frame.viewport.show_grid {
1195            let slot = &self.viewport_slots[vp_idx];
1196            let slot_hdr = slot.hdr.as_ref().unwrap();
1197            let grid_bg = &slot.grid_bind_group;
1198            let mut grid_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1199                label: Some("hdr_grid_pass"),
1200                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1201                    view: output_view,
1202                    resolve_target: None,
1203                    ops: wgpu::Operations {
1204                        load: wgpu::LoadOp::Load,
1205                        store: wgpu::StoreOp::Store,
1206                    },
1207                    depth_slice: None,
1208                })],
1209                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1210                    view: &slot_hdr.hdr_depth_view,
1211                    depth_ops: Some(wgpu::Operations {
1212                        load: wgpu::LoadOp::Load,
1213                        store: wgpu::StoreOp::Store,
1214                    }),
1215                    stencil_ops: None,
1216                }),
1217                timestamp_writes: None,
1218                occlusion_query_set: None,
1219            });
1220            grid_pass.set_pipeline(&self.resources.grid_pipeline);
1221            grid_pass.set_bind_group(0, grid_bg, &[]);
1222            grid_pass.draw(0..3, 0..1);
1223        }
1224
1225        // Ground plane pass (HDR path): drawn after grid, before editor overlays.
1226        // Uses the scene depth buffer for correct occlusion against geometry.
1227        if !matches!(
1228            frame.effects.ground_plane.mode,
1229            crate::renderer::types::GroundPlaneMode::None
1230        ) {
1231            let slot = &self.viewport_slots[vp_idx];
1232            let slot_hdr = slot.hdr.as_ref().unwrap();
1233            let mut gp_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1234                label: Some("hdr_ground_plane_pass"),
1235                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1236                    view: output_view,
1237                    resolve_target: None,
1238                    ops: wgpu::Operations {
1239                        load: wgpu::LoadOp::Load,
1240                        store: wgpu::StoreOp::Store,
1241                    },
1242                    depth_slice: None,
1243                })],
1244                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1245                    view: &slot_hdr.hdr_depth_view,
1246                    depth_ops: Some(wgpu::Operations {
1247                        load: wgpu::LoadOp::Load,
1248                        store: wgpu::StoreOp::Store,
1249                    }),
1250                    stencil_ops: None,
1251                }),
1252                timestamp_writes: None,
1253                occlusion_query_set: None,
1254            });
1255            gp_pass.set_pipeline(&self.resources.ground_plane_pipeline);
1256            gp_pass.set_bind_group(0, &self.resources.ground_plane_bind_group, &[]);
1257            gp_pass.draw(0..3, 0..1);
1258        }
1259
1260        // Editor overlay pass (HDR path): draw viewport/editor overlays on the
1261        // final output after tone mapping / FXAA, reusing the scene depth
1262        // buffer so depth-tested helpers still behave correctly.
1263        {
1264            let slot = &self.viewport_slots[vp_idx];
1265            let slot_hdr = slot.hdr.as_ref().unwrap();
1266            let has_editor_overlays = (frame.interaction.gizmo_model.is_some()
1267                && slot.gizmo_index_count > 0)
1268                || !slot.constraint_line_buffers.is_empty()
1269                || !slot.clip_plane_fill_buffers.is_empty()
1270                || !slot.clip_plane_line_buffers.is_empty()
1271                || !slot.xray_object_buffers.is_empty();
1272            if has_editor_overlays {
1273                let camera_bg = &slot.camera_bind_group;
1274                let mut overlay_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1275                    label: Some("hdr_editor_overlay_pass"),
1276                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1277                        view: output_view,
1278                        resolve_target: None,
1279                        ops: wgpu::Operations {
1280                            load: wgpu::LoadOp::Load,
1281                            store: wgpu::StoreOp::Store,
1282                        },
1283                        depth_slice: None,
1284                    })],
1285                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1286                        view: &slot_hdr.hdr_depth_view,
1287                        depth_ops: Some(wgpu::Operations {
1288                            load: wgpu::LoadOp::Load,
1289                            store: wgpu::StoreOp::Discard,
1290                        }),
1291                        stencil_ops: None,
1292                    }),
1293                    timestamp_writes: None,
1294                    occlusion_query_set: None,
1295                });
1296
1297                if frame.interaction.gizmo_model.is_some() && slot.gizmo_index_count > 0 {
1298                    overlay_pass.set_pipeline(&self.resources.gizmo_pipeline);
1299                    overlay_pass.set_bind_group(0, camera_bg, &[]);
1300                    overlay_pass.set_bind_group(1, &slot.gizmo_bind_group, &[]);
1301                    overlay_pass.set_vertex_buffer(0, slot.gizmo_vertex_buffer.slice(..));
1302                    overlay_pass.set_index_buffer(
1303                        slot.gizmo_index_buffer.slice(..),
1304                        wgpu::IndexFormat::Uint32,
1305                    );
1306                    overlay_pass.draw_indexed(0..slot.gizmo_index_count, 0, 0..1);
1307                }
1308
1309                if !slot.constraint_line_buffers.is_empty() {
1310                    overlay_pass.set_pipeline(&self.resources.overlay_line_pipeline);
1311                    overlay_pass.set_bind_group(0, camera_bg, &[]);
1312                    for (vbuf, ibuf, index_count, _ubuf, bg) in &slot.constraint_line_buffers {
1313                        overlay_pass.set_bind_group(1, bg, &[]);
1314                        overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
1315                        overlay_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
1316                        overlay_pass.draw_indexed(0..*index_count, 0, 0..1);
1317                    }
1318                }
1319
1320                if !slot.clip_plane_fill_buffers.is_empty() {
1321                    overlay_pass.set_pipeline(&self.resources.overlay_pipeline);
1322                    overlay_pass.set_bind_group(0, camera_bg, &[]);
1323                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.clip_plane_fill_buffers {
1324                        overlay_pass.set_bind_group(1, bg, &[]);
1325                        overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
1326                        overlay_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
1327                        overlay_pass.draw_indexed(0..*idx_count, 0, 0..1);
1328                    }
1329                }
1330
1331                if !slot.clip_plane_line_buffers.is_empty() {
1332                    overlay_pass.set_pipeline(&self.resources.overlay_line_pipeline);
1333                    overlay_pass.set_bind_group(0, camera_bg, &[]);
1334                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.clip_plane_line_buffers {
1335                        overlay_pass.set_bind_group(1, bg, &[]);
1336                        overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
1337                        overlay_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
1338                        overlay_pass.draw_indexed(0..*idx_count, 0, 0..1);
1339                    }
1340                }
1341
1342                if !slot.xray_object_buffers.is_empty() {
1343                    overlay_pass.set_pipeline(&self.resources.xray_pipeline);
1344                    overlay_pass.set_bind_group(0, camera_bg, &[]);
1345                    for (mesh_id, _buf, bg) in &slot.xray_object_buffers {
1346                        let Some(mesh) = self
1347                            .resources
1348                            .mesh_store
1349                            .get(*mesh_id)
1350                        else {
1351                            continue;
1352                        };
1353                        overlay_pass.set_bind_group(1, bg, &[]);
1354                        overlay_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1355                        overlay_pass.set_index_buffer(
1356                            mesh.index_buffer.slice(..),
1357                            wgpu::IndexFormat::Uint32,
1358                        );
1359                        overlay_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1360                    }
1361                }
1362            }
1363        }
1364
1365        // Axes indicator pass (HDR path): draw in screen space on the final
1366        // output after tone mapping / FXAA so it stays visible in PBR mode.
1367        if frame.viewport.show_axes_indicator {
1368            let slot = &self.viewport_slots[vp_idx];
1369            if slot.axes_vertex_count > 0 {
1370                let slot_hdr = slot.hdr.as_ref().unwrap();
1371                let mut axes_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1372                    label: Some("hdr_axes_pass"),
1373                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1374                        view: output_view,
1375                        resolve_target: None,
1376                        ops: wgpu::Operations {
1377                            load: wgpu::LoadOp::Load,
1378                            store: wgpu::StoreOp::Store,
1379                        },
1380                        depth_slice: None,
1381                    })],
1382                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1383                        view: &slot_hdr.hdr_depth_view,
1384                        depth_ops: Some(wgpu::Operations {
1385                            load: wgpu::LoadOp::Load,
1386                            store: wgpu::StoreOp::Discard,
1387                        }),
1388                        stencil_ops: None,
1389                    }),
1390                    timestamp_writes: None,
1391                    occlusion_query_set: None,
1392                });
1393                axes_pass.set_pipeline(&self.resources.axes_pipeline);
1394                axes_pass.set_vertex_buffer(0, slot.axes_vertex_buffer.slice(..));
1395                axes_pass.draw(0..slot.axes_vertex_count, 0..1);
1396            }
1397        }
1398
1399        // Phase 10B / Phase 12 : screen-space image overlay pass (HDR path).
1400        // Drawn after axes so overlays are always on top of everything.
1401        // Regular items use depth_compare: Always; depth-composite items use LessEqual.
1402        if !self.screen_image_gpu_data.is_empty() {
1403            if let Some(overlay_pipeline) = &self.resources.screen_image_pipeline {
1404                let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
1405                let dc_pipeline = self.resources.screen_image_dc_pipeline.as_ref();
1406                let mut img_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1407                    label: Some("screen_image_pass"),
1408                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1409                        view: output_view,
1410                        resolve_target: None,
1411                        ops: wgpu::Operations {
1412                            load: wgpu::LoadOp::Load,
1413                            store: wgpu::StoreOp::Store,
1414                        },
1415                        depth_slice: None,
1416                    })],
1417                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1418                        view: &slot_hdr.hdr_depth_view,
1419                        depth_ops: Some(wgpu::Operations {
1420                            load: wgpu::LoadOp::Load,
1421                            store: wgpu::StoreOp::Discard,
1422                        }),
1423                        stencil_ops: None,
1424                    }),
1425                    timestamp_writes: None,
1426                    occlusion_query_set: None,
1427                });
1428                for gpu in &self.screen_image_gpu_data {
1429                    if let (Some(dc_bg), Some(dc_pipe)) = (&gpu.depth_bind_group, dc_pipeline) {
1430                        img_pass.set_pipeline(dc_pipe);
1431                        img_pass.set_bind_group(0, dc_bg, &[]);
1432                    } else {
1433                        img_pass.set_pipeline(overlay_pipeline);
1434                        img_pass.set_bind_group(0, &gpu.bind_group, &[]);
1435                    }
1436                    img_pass.draw(0..6, 0..1);
1437                }
1438            }
1439        }
1440
1441        encoder.finish()
1442    }
1443
1444    /// Render a frame to an offscreen texture and return raw RGBA bytes.
1445    ///
1446    /// Creates a temporary [`wgpu::Texture`] render target of the given dimensions,
1447    /// runs all render passes (shadow, scene, post-processing) into it via
1448    /// [`render()`](Self::render), then copies the result back to CPU memory.
1449    ///
1450    /// No OS window or [`wgpu::Surface`] is required. The caller is responsible for
1451    /// initialising the wgpu adapter with `compatible_surface: None` and for
1452    /// constructing a valid [`FrameData`] (including `viewport_size` matching
1453    /// `width`/`height`).
1454    ///
1455    /// Returns `width * height * 4` bytes in RGBA8 layout. The caller encodes to
1456    /// PNG/EXR independently : no image codec dependency in this crate.
1457    pub fn render_offscreen(
1458        &mut self,
1459        device: &wgpu::Device,
1460        queue: &wgpu::Queue,
1461        frame: &FrameData,
1462        width: u32,
1463        height: u32,
1464    ) -> Vec<u8> {
1465        // 1. Create offscreen texture with RENDER_ATTACHMENT | COPY_SRC usage.
1466        let target_format = self.resources.target_format;
1467        let offscreen_texture = device.create_texture(&wgpu::TextureDescriptor {
1468            label: Some("offscreen_target"),
1469            size: wgpu::Extent3d {
1470                width: width.max(1),
1471                height: height.max(1),
1472                depth_or_array_layers: 1,
1473            },
1474            mip_level_count: 1,
1475            sample_count: 1,
1476            dimension: wgpu::TextureDimension::D2,
1477            format: target_format,
1478            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1479            view_formats: &[],
1480        });
1481
1482        // 2. Create a texture view for rendering into.
1483        let output_view = offscreen_texture.create_view(&wgpu::TextureViewDescriptor::default());
1484
1485        // 3. render() calls ensure_viewport_hdr which provides the depth-stencil buffer
1486        //    for both LDR and HDR paths, so no separate ensure_outline_target is needed.
1487
1488        // 4. Render the scene into the offscreen texture.
1489        //    The caller must set `frame.camera.viewport_size` to `[width as f32, height as f32]`
1490        //    and `frame.camera.render_camera.aspect` to `width as f32 / height as f32`
1491        //    for correct HDR target allocation and scissor rects.
1492        let cmd_buf = self.render(device, queue, &output_view, frame);
1493        queue.submit(std::iter::once(cmd_buf));
1494
1495        // 5. Copy texture -> staging buffer (wgpu requires row alignment to 256 bytes).
1496        let bytes_per_pixel = 4u32;
1497        let unpadded_row = width * bytes_per_pixel;
1498        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
1499        let padded_row = (unpadded_row + align - 1) & !(align - 1);
1500        let buffer_size = (padded_row * height.max(1)) as u64;
1501
1502        let staging_buf = device.create_buffer(&wgpu::BufferDescriptor {
1503            label: Some("offscreen_staging"),
1504            size: buffer_size,
1505            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1506            mapped_at_creation: false,
1507        });
1508
1509        let mut copy_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1510            label: Some("offscreen_copy_encoder"),
1511        });
1512        copy_encoder.copy_texture_to_buffer(
1513            wgpu::TexelCopyTextureInfo {
1514                texture: &offscreen_texture,
1515                mip_level: 0,
1516                origin: wgpu::Origin3d::ZERO,
1517                aspect: wgpu::TextureAspect::All,
1518            },
1519            wgpu::TexelCopyBufferInfo {
1520                buffer: &staging_buf,
1521                layout: wgpu::TexelCopyBufferLayout {
1522                    offset: 0,
1523                    bytes_per_row: Some(padded_row),
1524                    rows_per_image: Some(height.max(1)),
1525                },
1526            },
1527            wgpu::Extent3d {
1528                width: width.max(1),
1529                height: height.max(1),
1530                depth_or_array_layers: 1,
1531            },
1532        );
1533        queue.submit(std::iter::once(copy_encoder.finish()));
1534
1535        // 6. Map buffer and extract tightly-packed RGBA pixels.
1536        let (tx, rx) = std::sync::mpsc::channel();
1537        staging_buf
1538            .slice(..)
1539            .map_async(wgpu::MapMode::Read, move |result| {
1540                let _ = tx.send(result);
1541            });
1542        device
1543            .poll(wgpu::PollType::Wait {
1544                submission_index: None,
1545                timeout: Some(std::time::Duration::from_secs(5)),
1546            })
1547            .unwrap();
1548        let _ = rx.recv().unwrap_or(Err(wgpu::BufferAsyncError));
1549
1550        let mut pixels: Vec<u8> = Vec::with_capacity((width * height * 4) as usize);
1551        {
1552            let mapped = staging_buf.slice(..).get_mapped_range();
1553            let data: &[u8] = &mapped;
1554            if padded_row == unpadded_row {
1555                // No padding : copy entire slice directly.
1556                pixels.extend_from_slice(data);
1557            } else {
1558                // Strip row padding.
1559                for row in 0..height as usize {
1560                    let start = row * padded_row as usize;
1561                    let end = start + unpadded_row as usize;
1562                    pixels.extend_from_slice(&data[start..end]);
1563                }
1564            }
1565        }
1566        staging_buf.unmap();
1567
1568        // 7. Swizzle BGRA -> RGBA if the format stores bytes in BGRA order.
1569        let is_bgra = matches!(
1570            target_format,
1571            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
1572        );
1573        if is_bgra {
1574            for pixel in pixels.chunks_exact_mut(4) {
1575                pixel.swap(0, 2); // B ↔ R
1576            }
1577        }
1578
1579        pixels
1580    }
1581}