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