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