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()
416                                    || item.two_sided
417                                    || item.material.matcap_id.is_some())
418                                && resources
419                                    .mesh_store
420                                    .get(crate::resources::mesh_store::MeshId(item.mesh_index))
421                                    .is_some()
422                        })
423                        .collect();
424
425                    // Separate opaque and transparent batches.
426                    let mut opaque_batches: Vec<&InstancedBatch> = Vec::new();
427                    let mut transparent_batches: Vec<&InstancedBatch> = Vec::new();
428                    for batch in batches {
429                        if batch.is_transparent {
430                            transparent_batches.push(batch);
431                        } else {
432                            opaque_batches.push(batch);
433                        }
434                    }
435
436                    if !opaque_batches.is_empty() && !frame.viewport.wireframe_mode {
437                        if let Some(ref pipeline) = resources.hdr_solid_instanced_pipeline {
438                            render_pass.set_pipeline(pipeline);
439                            for batch in &opaque_batches {
440                                let Some(mesh) = resources
441                                    .mesh_store
442                                    .get(crate::resources::mesh_store::MeshId(batch.mesh_index))
443                                else {
444                                    continue;
445                                };
446                                let mat_key = (
447                                    batch.texture_id.unwrap_or(u64::MAX),
448                                    batch.normal_map_id.unwrap_or(u64::MAX),
449                                    batch.ao_map_id.unwrap_or(u64::MAX),
450                                );
451                                let Some(inst_tex_bg) =
452                                    resources.instance_bind_groups.get(&mat_key)
453                                else {
454                                    continue;
455                                };
456                                render_pass.set_bind_group(1, inst_tex_bg, &[]);
457                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
458                                render_pass.set_index_buffer(
459                                    mesh.index_buffer.slice(..),
460                                    wgpu::IndexFormat::Uint32,
461                                );
462                                render_pass.draw_indexed(
463                                    0..mesh.index_count,
464                                    0,
465                                    batch.instance_offset
466                                        ..batch.instance_offset + batch.instance_count,
467                                );
468                            }
469                        }
470                    }
471
472                    // NOTE: transparent_batches are now rendered in the OIT pass below,
473                    // not in the HDR scene pass. This block intentionally left empty.
474                    let _ = &transparent_batches; // suppress unused warning
475
476                    if frame.viewport.wireframe_mode {
477                        if let Some(ref hdr_wf) = resources.hdr_wireframe_pipeline {
478                            render_pass.set_pipeline(hdr_wf);
479                            for item in scene_items {
480                                if !item.visible {
481                                    continue;
482                                }
483                                let Some(mesh) = resources
484                                    .mesh_store
485                                    .get(crate::resources::mesh_store::MeshId(item.mesh_index))
486                                else {
487                                    continue;
488                                };
489                                render_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
490                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
491                                render_pass.set_index_buffer(
492                                    mesh.edge_index_buffer.slice(..),
493                                    wgpu::IndexFormat::Uint32,
494                                );
495                                render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
496                            }
497                        }
498                    } else if let (Some(hdr_solid), Some(hdr_solid_two_sided)) = (
499                        &resources.hdr_solid_pipeline,
500                        &resources.hdr_solid_two_sided_pipeline,
501                    ) {
502                        for item in excluded_items
503                            .into_iter()
504                            .filter(|item| item.material.opacity >= 1.0)
505                        {
506                            let Some(mesh) = resources
507                                .mesh_store
508                                .get(crate::resources::mesh_store::MeshId(item.mesh_index))
509                            else {
510                                continue;
511                            };
512                            let pipeline = if item.two_sided {
513                                hdr_solid_two_sided
514                            } else {
515                                hdr_solid
516                            };
517                            render_pass.set_pipeline(pipeline);
518                            render_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
519                            render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
520                            render_pass.set_index_buffer(
521                                mesh.index_buffer.slice(..),
522                                wgpu::IndexFormat::Uint32,
523                            );
524                            render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
525                        }
526                    }
527                } else {
528                    // Per-object path.
529                    let eye = glam::Vec3::from(frame.camera.render_camera.eye_position);
530                    let dist_from_eye = |item: &&SceneRenderItem| -> f32 {
531                        let pos =
532                            glam::Vec3::new(item.model[3][0], item.model[3][1], item.model[3][2]);
533                        (pos - eye).length()
534                    };
535
536                    let mut opaque: Vec<&SceneRenderItem> = Vec::new();
537                    let mut transparent: Vec<&SceneRenderItem> = Vec::new();
538                    for item in scene_items {
539                        if !item.visible
540                            || resources
541                                .mesh_store
542                                .get(crate::resources::mesh_store::MeshId(item.mesh_index))
543                                .is_none()
544                        {
545                            continue;
546                        }
547                        if item.material.opacity < 1.0 {
548                            transparent.push(item);
549                        } else {
550                            opaque.push(item);
551                        }
552                    }
553                    opaque.sort_by(|a, b| {
554                        dist_from_eye(a)
555                            .partial_cmp(&dist_from_eye(b))
556                            .unwrap_or(std::cmp::Ordering::Equal)
557                    });
558                    transparent.sort_by(|a, b| {
559                        dist_from_eye(b)
560                            .partial_cmp(&dist_from_eye(a))
561                            .unwrap_or(std::cmp::Ordering::Equal)
562                    });
563
564                    let draw_item_hdr =
565                        |render_pass: &mut wgpu::RenderPass<'_>,
566                         item: &SceneRenderItem,
567                         solid_pl: &wgpu::RenderPipeline,
568                         trans_pl: &wgpu::RenderPipeline,
569                         wf_pl: &wgpu::RenderPipeline| {
570                            let mesh = resources
571                                .mesh_store
572                                .get(crate::resources::mesh_store::MeshId(item.mesh_index))
573                                .unwrap();
574                            // mesh.object_bind_group (group 1) already carries the object uniform
575                            // and the correct texture views.
576                            render_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
577                            let is_face_attr =
578                                item.active_attribute.as_ref().map_or(false, |a| {
579                                    matches!(
580                                        a.kind,
581                                        crate::resources::AttributeKind::Face
582                                            | crate::resources::AttributeKind::FaceColor
583                                    )
584                                });
585                            if frame.viewport.wireframe_mode {
586                                render_pass.set_pipeline(wf_pl);
587                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
588                                render_pass.set_index_buffer(
589                                    mesh.edge_index_buffer.slice(..),
590                                    wgpu::IndexFormat::Uint32,
591                                );
592                                render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
593                            } else if is_face_attr {
594                                if let Some(ref fvb) = mesh.face_vertex_buffer {
595                                    let pl = if item.material.opacity < 1.0 {
596                                        trans_pl
597                                    } else {
598                                        solid_pl
599                                    };
600                                    render_pass.set_pipeline(pl);
601                                    render_pass.set_vertex_buffer(0, fvb.slice(..));
602                                    render_pass.draw(0..mesh.index_count, 0..1);
603                                }
604                            } else if item.material.opacity < 1.0 {
605                                render_pass.set_pipeline(trans_pl);
606                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
607                                render_pass.set_index_buffer(
608                                    mesh.index_buffer.slice(..),
609                                    wgpu::IndexFormat::Uint32,
610                                );
611                                render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
612                            } else {
613                                render_pass.set_pipeline(solid_pl);
614                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
615                                render_pass.set_index_buffer(
616                                    mesh.index_buffer.slice(..),
617                                    wgpu::IndexFormat::Uint32,
618                                );
619                                render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
620                            }
621                        };
622
623                    // NOTE: only opaque items are drawn here. Transparent items are
624                    // routed to the OIT pass below.
625                    let _ = &transparent; // suppress unused warning
626                    if let (
627                        Some(hdr_solid),
628                        Some(hdr_solid_two_sided),
629                        Some(hdr_trans),
630                        Some(hdr_wf),
631                    ) = (
632                        &resources.hdr_solid_pipeline,
633                        &resources.hdr_solid_two_sided_pipeline,
634                        &resources.hdr_transparent_pipeline,
635                        &resources.hdr_wireframe_pipeline,
636                    ) {
637                        for item in &opaque {
638                            let solid_pl = if item.two_sided {
639                                hdr_solid_two_sided
640                            } else {
641                                hdr_solid
642                            };
643                            draw_item_hdr(&mut render_pass, item, solid_pl, hdr_trans, hdr_wf);
644                        }
645                    }
646                }
647            }
648
649            // Cap fill pass (HDR path — section view cross-section fill).
650            if !slot.cap_buffers.is_empty() {
651                if let Some(ref hdr_overlay) = resources.hdr_overlay_pipeline {
652                    render_pass.set_pipeline(hdr_overlay);
653                    render_pass.set_bind_group(0, camera_bg, &[]);
654                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.cap_buffers {
655                        render_pass.set_bind_group(1, bg, &[]);
656                        render_pass.set_vertex_buffer(0, vbuf.slice(..));
657                        render_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
658                        render_pass.draw_indexed(0..*idx_count, 0, 0..1);
659                    }
660                }
661            }
662
663            // SciVis Phase B+D+M8+M: point cloud, glyph, polyline, volume, streamtube (HDR path).
664            emit_scivis_draw_calls!(
665                &self.resources,
666                &mut render_pass,
667                &self.point_cloud_gpu_data,
668                &self.glyph_gpu_data,
669                &self.polyline_gpu_data,
670                &self.volume_gpu_data,
671                &self.streamtube_gpu_data,
672                camera_bg
673            );
674
675            // Draw skybox last among opaques — only uncovered sky pixels pass depth == 1.0.
676            if show_skybox {
677                render_pass.set_bind_group(0, camera_bg, &[]);
678                render_pass.set_pipeline(&resources.skybox_pipeline);
679                render_pass.draw(0..3, 0..1);
680            }
681        }
682
683        // -----------------------------------------------------------------------
684        // OIT pass: render transparent items into accum + reveal textures.
685        // Completely skipped when no transparent items exist (zero overhead).
686        // -----------------------------------------------------------------------
687        let has_transparent = if self.use_instancing && !self.instanced_batches.is_empty() {
688            self.instanced_batches.iter().any(|b| b.is_transparent)
689        } else {
690            scene_items
691                .iter()
692                .any(|i| i.visible && i.material.opacity < 1.0)
693        };
694
695        if has_transparent {
696            // OIT targets already allocated in the pre-pass above.
697            if let (Some(accum_view), Some(reveal_view)) = (
698                slot_hdr.oit_accum_view.as_ref(),
699                slot_hdr.oit_reveal_view.as_ref(),
700            ) {
701                let hdr_depth_view = &slot_hdr.hdr_depth_view;
702                // Clear accum to (0,0,0,0), reveal to 1.0 (no contribution yet).
703                let mut oit_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
704                    label: Some("oit_pass"),
705                    color_attachments: &[
706                        Some(wgpu::RenderPassColorAttachment {
707                            view: accum_view,
708                            resolve_target: None,
709                            ops: wgpu::Operations {
710                                load: wgpu::LoadOp::Clear(wgpu::Color {
711                                    r: 0.0,
712                                    g: 0.0,
713                                    b: 0.0,
714                                    a: 0.0,
715                                }),
716                                store: wgpu::StoreOp::Store,
717                            },
718                            depth_slice: None,
719                        }),
720                        Some(wgpu::RenderPassColorAttachment {
721                            view: reveal_view,
722                            resolve_target: None,
723                            ops: wgpu::Operations {
724                                load: wgpu::LoadOp::Clear(wgpu::Color {
725                                    r: 1.0,
726                                    g: 1.0,
727                                    b: 1.0,
728                                    a: 1.0,
729                                }),
730                                store: wgpu::StoreOp::Store,
731                            },
732                            depth_slice: None,
733                        }),
734                    ],
735                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
736                        view: hdr_depth_view,
737                        depth_ops: Some(wgpu::Operations {
738                            load: wgpu::LoadOp::Load, // reuse opaque depth
739                            store: wgpu::StoreOp::Store,
740                        }),
741                        stencil_ops: None,
742                    }),
743                    timestamp_writes: None,
744                    occlusion_query_set: None,
745                });
746
747                oit_pass.set_bind_group(0, camera_bg, &[]);
748
749                if self.use_instancing && !self.instanced_batches.is_empty() {
750                    if let Some(ref pipeline) = self.resources.oit_instanced_pipeline {
751                        oit_pass.set_pipeline(pipeline);
752                        for batch in &self.instanced_batches {
753                            if !batch.is_transparent {
754                                continue;
755                            }
756                            let Some(mesh) = self
757                                .resources
758                                .mesh_store
759                                .get(crate::resources::mesh_store::MeshId(batch.mesh_index))
760                            else {
761                                continue;
762                            };
763                            let mat_key = (
764                                batch.texture_id.unwrap_or(u64::MAX),
765                                batch.normal_map_id.unwrap_or(u64::MAX),
766                                batch.ao_map_id.unwrap_or(u64::MAX),
767                            );
768                            let Some(inst_tex_bg) =
769                                self.resources.instance_bind_groups.get(&mat_key)
770                            else {
771                                continue;
772                            };
773                            oit_pass.set_bind_group(1, inst_tex_bg, &[]);
774                            oit_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
775                            oit_pass.set_index_buffer(
776                                mesh.index_buffer.slice(..),
777                                wgpu::IndexFormat::Uint32,
778                            );
779                            oit_pass.draw_indexed(
780                                0..mesh.index_count,
781                                0,
782                                batch.instance_offset..batch.instance_offset + batch.instance_count,
783                            );
784                        }
785                    }
786                } else if let Some(ref pipeline) = self.resources.oit_pipeline {
787                    oit_pass.set_pipeline(pipeline);
788                    for item in scene_items {
789                        if !item.visible || item.material.opacity >= 1.0 {
790                            continue;
791                        }
792                        let Some(mesh) = self
793                            .resources
794                            .mesh_store
795                            .get(crate::resources::mesh_store::MeshId(item.mesh_index))
796                        else {
797                            continue;
798                        };
799                        oit_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
800                        oit_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
801                        oit_pass.set_index_buffer(
802                            mesh.index_buffer.slice(..),
803                            wgpu::IndexFormat::Uint32,
804                        );
805                        oit_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
806                    }
807                }
808            }
809        }
810
811        // -----------------------------------------------------------------------
812        // OIT composite pass: blend accum/reveal into HDR buffer.
813        // Only executes when transparent items were present.
814        // -----------------------------------------------------------------------
815        if has_transparent {
816            if let (Some(pipeline), Some(bg)) = (
817                self.resources.oit_composite_pipeline.as_ref(),
818                slot_hdr.oit_composite_bind_group.as_ref(),
819            ) {
820                let hdr_view = &slot_hdr.hdr_view;
821                let mut composite_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
822                    label: Some("oit_composite_pass"),
823                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
824                        view: hdr_view,
825                        resolve_target: None,
826                        ops: wgpu::Operations {
827                            load: wgpu::LoadOp::Load,
828                            store: wgpu::StoreOp::Store,
829                        },
830                        depth_slice: None,
831                    })],
832                    depth_stencil_attachment: None,
833                    timestamp_writes: None,
834                    occlusion_query_set: None,
835                });
836                composite_pass.set_pipeline(pipeline);
837                composite_pass.set_bind_group(0, bg, &[]);
838                composite_pass.draw(0..3, 0..1);
839            }
840        }
841
842        // -----------------------------------------------------------------------
843        // Outline composite pass (HDR path): blit offscreen outline onto hdr_view.
844        // Runs after the HDR scene pass (which has depth+stencil) in a separate
845        // pass with no depth attachment, so the composite pipeline is compatible.
846        // -----------------------------------------------------------------------
847        if !slot.outline_object_buffers.is_empty() {
848            // Prefer the HDR-format pipeline; fall back to LDR single-sample.
849            let hdr_pipeline = self
850                .resources
851                .outline_composite_pipeline_hdr
852                .as_ref()
853                .or(self.resources.outline_composite_pipeline_single.as_ref());
854            if let Some(pipeline) = hdr_pipeline {
855                let bg = &slot_hdr.outline_composite_bind_group;
856                let hdr_view = &slot_hdr.hdr_view;
857                let hdr_depth_view = &slot_hdr.hdr_depth_view;
858                let mut outline_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
859                    label: Some("hdr_outline_composite_pass"),
860                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
861                        view: hdr_view,
862                        resolve_target: None,
863                        ops: wgpu::Operations {
864                            load: wgpu::LoadOp::Load,
865                            store: wgpu::StoreOp::Store,
866                        },
867                        depth_slice: None,
868                    })],
869                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
870                        view: hdr_depth_view,
871                        depth_ops: Some(wgpu::Operations {
872                            load: wgpu::LoadOp::Load,
873                            store: wgpu::StoreOp::Store,
874                        }),
875                        stencil_ops: None,
876                    }),
877                    timestamp_writes: None,
878                    occlusion_query_set: None,
879                });
880                outline_pass.set_pipeline(pipeline);
881                outline_pass.set_bind_group(0, bg, &[]);
882                outline_pass.draw(0..3, 0..1);
883            }
884        }
885
886        // -----------------------------------------------------------------------
887        // SSAO pass.
888        // -----------------------------------------------------------------------
889        if pp.ssao {
890            if let Some(ssao_pipeline) = &self.resources.ssao_pipeline {
891                {
892                    let mut ssao_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
893                        label: Some("ssao_pass"),
894                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
895                            view: &slot_hdr.ssao_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_pass.set_pipeline(ssao_pipeline);
908                    ssao_pass.set_bind_group(0, &slot_hdr.ssao_bg, &[]);
909                    ssao_pass.draw(0..3, 0..1);
910                }
911
912                // SSAO blur pass.
913                if let Some(ssao_blur_pipeline) = &self.resources.ssao_blur_pipeline {
914                    let mut ssao_blur_pass =
915                        encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
916                            label: Some("ssao_blur_pass"),
917                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
918                                view: &slot_hdr.ssao_blur_view,
919                                resolve_target: None,
920                                ops: wgpu::Operations {
921                                    load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
922                                    store: wgpu::StoreOp::Store,
923                                },
924                                depth_slice: None,
925                            })],
926                            depth_stencil_attachment: None,
927                            timestamp_writes: None,
928                            occlusion_query_set: None,
929                        });
930                    ssao_blur_pass.set_pipeline(ssao_blur_pipeline);
931                    ssao_blur_pass.set_bind_group(0, &slot_hdr.ssao_blur_bg, &[]);
932                    ssao_blur_pass.draw(0..3, 0..1);
933                }
934            }
935        }
936
937        // -----------------------------------------------------------------------
938        // Contact shadow pass.
939        // -----------------------------------------------------------------------
940        if pp.contact_shadows {
941            if let Some(cs_pipeline) = &self.resources.contact_shadow_pipeline {
942                let mut cs_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
943                    label: Some("contact_shadow_pass"),
944                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
945                        view: &slot_hdr.contact_shadow_view,
946                        resolve_target: None,
947                        depth_slice: None,
948                        ops: wgpu::Operations {
949                            load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
950                            store: wgpu::StoreOp::Store,
951                        },
952                    })],
953                    depth_stencil_attachment: None,
954                    timestamp_writes: None,
955                    occlusion_query_set: None,
956                });
957                cs_pass.set_pipeline(cs_pipeline);
958                cs_pass.set_bind_group(0, &slot_hdr.contact_shadow_bg, &[]);
959                cs_pass.draw(0..3, 0..1);
960            }
961        }
962
963        // -----------------------------------------------------------------------
964        // Bloom passes.
965        // -----------------------------------------------------------------------
966        if pp.bloom {
967            // Threshold pass: extract bright pixels into bloom_threshold_texture.
968            if let Some(bloom_threshold_pipeline) = &self.resources.bloom_threshold_pipeline {
969                {
970                    let mut threshold_pass =
971                        encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
972                            label: Some("bloom_threshold_pass"),
973                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
974                                view: &slot_hdr.bloom_threshold_view,
975                                resolve_target: None,
976                                ops: wgpu::Operations {
977                                    load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
978                                    store: wgpu::StoreOp::Store,
979                                },
980                                depth_slice: None,
981                            })],
982                            depth_stencil_attachment: None,
983                            timestamp_writes: None,
984                            occlusion_query_set: None,
985                        });
986                    threshold_pass.set_pipeline(bloom_threshold_pipeline);
987                    threshold_pass.set_bind_group(0, &slot_hdr.bloom_threshold_bg, &[]);
988                    threshold_pass.draw(0..3, 0..1);
989                }
990
991                // 4 ping-pong H+V blur passes for a wide glow.
992                // Pass 1: threshold -> ping -> pong. Passes 2-4: pong -> ping -> pong.
993                if let Some(blur_pipeline) = &self.resources.bloom_blur_pipeline {
994                    let blur_h_bg = &slot_hdr.bloom_blur_h_bg;
995                    let blur_h_pong_bg = &slot_hdr.bloom_blur_h_pong_bg;
996                    let blur_v_bg = &slot_hdr.bloom_blur_v_bg;
997                    let bloom_ping_view = &slot_hdr.bloom_ping_view;
998                    let bloom_pong_view = &slot_hdr.bloom_pong_view;
999                    const BLUR_ITERATIONS: usize = 4;
1000                    for i in 0..BLUR_ITERATIONS {
1001                        // H pass: pass 0 reads threshold, subsequent passes read pong.
1002                        let h_bg = if i == 0 { blur_h_bg } else { blur_h_pong_bg };
1003                        {
1004                            let mut h_pass =
1005                                encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1006                                    label: Some("bloom_blur_h_pass"),
1007                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1008                                        view: bloom_ping_view,
1009                                        resolve_target: None,
1010                                        ops: wgpu::Operations {
1011                                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1012                                            store: wgpu::StoreOp::Store,
1013                                        },
1014                                        depth_slice: None,
1015                                    })],
1016                                    depth_stencil_attachment: None,
1017                                    timestamp_writes: None,
1018                                    occlusion_query_set: None,
1019                                });
1020                            h_pass.set_pipeline(blur_pipeline);
1021                            h_pass.set_bind_group(0, h_bg, &[]);
1022                            h_pass.draw(0..3, 0..1);
1023                        }
1024                        // V pass: ping -> pong.
1025                        {
1026                            let mut v_pass =
1027                                encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1028                                    label: Some("bloom_blur_v_pass"),
1029                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1030                                        view: bloom_pong_view,
1031                                        resolve_target: None,
1032                                        ops: wgpu::Operations {
1033                                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1034                                            store: wgpu::StoreOp::Store,
1035                                        },
1036                                        depth_slice: None,
1037                                    })],
1038                                    depth_stencil_attachment: None,
1039                                    timestamp_writes: None,
1040                                    occlusion_query_set: None,
1041                                });
1042                            v_pass.set_pipeline(blur_pipeline);
1043                            v_pass.set_bind_group(0, blur_v_bg, &[]);
1044                            v_pass.draw(0..3, 0..1);
1045                        }
1046                    }
1047                }
1048            }
1049        }
1050
1051        // -----------------------------------------------------------------------
1052        // Tone map pass: HDR + bloom + AO -> (fxaa_texture if FXAA) or output_view.
1053        // -----------------------------------------------------------------------
1054        let use_fxaa = pp.fxaa;
1055        if let Some(tone_map_pipeline) = &self.resources.tone_map_pipeline {
1056            let tone_target: &wgpu::TextureView = if use_fxaa {
1057                &slot_hdr.fxaa_view
1058            } else {
1059                output_view
1060            };
1061            let mut tone_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1062                label: Some("tone_map_pass"),
1063                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1064                    view: tone_target,
1065                    resolve_target: None,
1066                    ops: wgpu::Operations {
1067                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1068                        store: wgpu::StoreOp::Store,
1069                    },
1070                    depth_slice: None,
1071                })],
1072                depth_stencil_attachment: None,
1073                timestamp_writes: None,
1074                occlusion_query_set: None,
1075            });
1076            tone_pass.set_pipeline(tone_map_pipeline);
1077            tone_pass.set_bind_group(0, &slot_hdr.tone_map_bind_group, &[]);
1078            tone_pass.draw(0..3, 0..1);
1079        }
1080
1081        // -----------------------------------------------------------------------
1082        // FXAA pass: fxaa_texture -> output_view (only when FXAA is enabled).
1083        // -----------------------------------------------------------------------
1084        if use_fxaa {
1085            if let Some(fxaa_pipeline) = &self.resources.fxaa_pipeline {
1086                let mut fxaa_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1087                    label: Some("fxaa_pass"),
1088                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1089                        view: output_view,
1090                        resolve_target: None,
1091                        ops: wgpu::Operations {
1092                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1093                            store: wgpu::StoreOp::Store,
1094                        },
1095                        depth_slice: None,
1096                    })],
1097                    depth_stencil_attachment: None,
1098                    timestamp_writes: None,
1099                    occlusion_query_set: None,
1100                });
1101                fxaa_pass.set_pipeline(fxaa_pipeline);
1102                fxaa_pass.set_bind_group(0, &slot_hdr.fxaa_bind_group, &[]);
1103                fxaa_pass.draw(0..3, 0..1);
1104            }
1105        }
1106
1107        // Grid pass (HDR path): draw the existing analytical grid on the final
1108        // output after tone mapping / FXAA, reusing the scene depth buffer so
1109        // scene geometry still occludes the grid exactly as in the LDR path.
1110        if frame.viewport.show_grid {
1111            let slot = &self.viewport_slots[vp_idx];
1112            let slot_hdr = slot.hdr.as_ref().unwrap();
1113            let grid_bg = &slot.grid_bind_group;
1114            let mut grid_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1115                label: Some("hdr_grid_pass"),
1116                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1117                    view: output_view,
1118                    resolve_target: None,
1119                    ops: wgpu::Operations {
1120                        load: wgpu::LoadOp::Load,
1121                        store: wgpu::StoreOp::Store,
1122                    },
1123                    depth_slice: None,
1124                })],
1125                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1126                    view: &slot_hdr.hdr_depth_view,
1127                    depth_ops: Some(wgpu::Operations {
1128                        load: wgpu::LoadOp::Load,
1129                        store: wgpu::StoreOp::Store,
1130                    }),
1131                    stencil_ops: None,
1132                }),
1133                timestamp_writes: None,
1134                occlusion_query_set: None,
1135            });
1136            grid_pass.set_pipeline(&self.resources.grid_pipeline);
1137            grid_pass.set_bind_group(0, grid_bg, &[]);
1138            grid_pass.draw(0..3, 0..1);
1139        }
1140
1141        // Editor overlay pass (HDR path): draw viewport/editor overlays on the
1142        // final output after tone mapping / FXAA, reusing the scene depth
1143        // buffer so depth-tested helpers still behave correctly.
1144        {
1145            let slot = &self.viewport_slots[vp_idx];
1146            let slot_hdr = slot.hdr.as_ref().unwrap();
1147            let has_editor_overlays =
1148                (frame.interaction.gizmo_model.is_some() && slot.gizmo_index_count > 0)
1149                || !slot.constraint_line_buffers.is_empty()
1150                || !slot.clip_plane_fill_buffers.is_empty()
1151                || !slot.clip_plane_line_buffers.is_empty()
1152                || !slot.xray_object_buffers.is_empty();
1153            if has_editor_overlays {
1154                let camera_bg = &slot.camera_bind_group;
1155                let mut overlay_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1156                    label: Some("hdr_editor_overlay_pass"),
1157                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1158                        view: output_view,
1159                        resolve_target: None,
1160                        ops: wgpu::Operations {
1161                            load: wgpu::LoadOp::Load,
1162                            store: wgpu::StoreOp::Store,
1163                        },
1164                        depth_slice: None,
1165                    })],
1166                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1167                        view: &slot_hdr.hdr_depth_view,
1168                        depth_ops: Some(wgpu::Operations {
1169                            load: wgpu::LoadOp::Load,
1170                            store: wgpu::StoreOp::Discard,
1171                        }),
1172                        stencil_ops: None,
1173                    }),
1174                    timestamp_writes: None,
1175                    occlusion_query_set: None,
1176                });
1177
1178                if frame.interaction.gizmo_model.is_some() && slot.gizmo_index_count > 0 {
1179                    overlay_pass.set_pipeline(&self.resources.gizmo_pipeline);
1180                    overlay_pass.set_bind_group(0, camera_bg, &[]);
1181                    overlay_pass.set_bind_group(1, &slot.gizmo_bind_group, &[]);
1182                    overlay_pass.set_vertex_buffer(0, slot.gizmo_vertex_buffer.slice(..));
1183                    overlay_pass.set_index_buffer(
1184                        slot.gizmo_index_buffer.slice(..),
1185                        wgpu::IndexFormat::Uint32,
1186                    );
1187                    overlay_pass.draw_indexed(0..slot.gizmo_index_count, 0, 0..1);
1188                }
1189
1190                if !slot.constraint_line_buffers.is_empty() {
1191                    overlay_pass.set_pipeline(&self.resources.overlay_line_pipeline);
1192                    overlay_pass.set_bind_group(0, camera_bg, &[]);
1193                    for (vbuf, ibuf, index_count, _ubuf, bg) in &slot.constraint_line_buffers {
1194                        overlay_pass.set_bind_group(1, bg, &[]);
1195                        overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
1196                        overlay_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
1197                        overlay_pass.draw_indexed(0..*index_count, 0, 0..1);
1198                    }
1199                }
1200
1201                if !slot.clip_plane_fill_buffers.is_empty() {
1202                    overlay_pass.set_pipeline(&self.resources.overlay_pipeline);
1203                    overlay_pass.set_bind_group(0, camera_bg, &[]);
1204                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.clip_plane_fill_buffers {
1205                        overlay_pass.set_bind_group(1, bg, &[]);
1206                        overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
1207                        overlay_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
1208                        overlay_pass.draw_indexed(0..*idx_count, 0, 0..1);
1209                    }
1210                }
1211
1212                if !slot.clip_plane_line_buffers.is_empty() {
1213                    overlay_pass.set_pipeline(&self.resources.overlay_line_pipeline);
1214                    overlay_pass.set_bind_group(0, camera_bg, &[]);
1215                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.clip_plane_line_buffers {
1216                        overlay_pass.set_bind_group(1, bg, &[]);
1217                        overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
1218                        overlay_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
1219                        overlay_pass.draw_indexed(0..*idx_count, 0, 0..1);
1220                    }
1221                }
1222
1223                if !slot.xray_object_buffers.is_empty() {
1224                    overlay_pass.set_pipeline(&self.resources.xray_pipeline);
1225                    overlay_pass.set_bind_group(0, camera_bg, &[]);
1226                    for (mesh_idx, _buf, bg) in &slot.xray_object_buffers {
1227                        let Some(mesh) = self
1228                            .resources
1229                            .mesh_store
1230                            .get(crate::resources::mesh_store::MeshId(*mesh_idx))
1231                        else {
1232                            continue;
1233                        };
1234                        overlay_pass.set_bind_group(1, bg, &[]);
1235                        overlay_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1236                        overlay_pass.set_index_buffer(
1237                            mesh.index_buffer.slice(..),
1238                            wgpu::IndexFormat::Uint32,
1239                        );
1240                        overlay_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1241                    }
1242                }
1243            }
1244        }
1245
1246        // Axes indicator pass (HDR path): draw in screen space on the final
1247        // output after tone mapping / FXAA so it stays visible in PBR mode.
1248        if frame.viewport.show_axes_indicator {
1249            let slot = &self.viewport_slots[vp_idx];
1250            if slot.axes_vertex_count > 0 {
1251                let slot_hdr = slot.hdr.as_ref().unwrap();
1252                let mut axes_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1253                    label: Some("hdr_axes_pass"),
1254                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1255                        view: output_view,
1256                        resolve_target: None,
1257                        ops: wgpu::Operations {
1258                            load: wgpu::LoadOp::Load,
1259                            store: wgpu::StoreOp::Store,
1260                        },
1261                        depth_slice: None,
1262                    })],
1263                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1264                        view: &slot_hdr.hdr_depth_view,
1265                        depth_ops: Some(wgpu::Operations {
1266                            load: wgpu::LoadOp::Load,
1267                            store: wgpu::StoreOp::Discard,
1268                        }),
1269                        stencil_ops: None,
1270                    }),
1271                    timestamp_writes: None,
1272                    occlusion_query_set: None,
1273                });
1274                axes_pass.set_pipeline(&self.resources.axes_pipeline);
1275                axes_pass.set_vertex_buffer(0, slot.axes_vertex_buffer.slice(..));
1276                axes_pass.draw(0..slot.axes_vertex_count, 0..1);
1277            }
1278        }
1279
1280        encoder.finish()
1281    }
1282
1283    /// Render a frame to an offscreen texture and return raw RGBA bytes.
1284    ///
1285    /// Creates a temporary [`wgpu::Texture`] render target of the given dimensions,
1286    /// runs all render passes (shadow, scene, post-processing) into it via
1287    /// [`render()`](Self::render), then copies the result back to CPU memory.
1288    ///
1289    /// No OS window or [`wgpu::Surface`] is required. The caller is responsible for
1290    /// initialising the wgpu adapter with `compatible_surface: None` and for
1291    /// constructing a valid [`FrameData`] (including `viewport_size` matching
1292    /// `width`/`height`).
1293    ///
1294    /// Returns `width * height * 4` bytes in RGBA8 layout. The caller encodes to
1295    /// PNG/EXR independently — no image codec dependency in this crate.
1296    pub fn render_offscreen(
1297        &mut self,
1298        device: &wgpu::Device,
1299        queue: &wgpu::Queue,
1300        frame: &FrameData,
1301        width: u32,
1302        height: u32,
1303    ) -> Vec<u8> {
1304        // 1. Create offscreen texture with RENDER_ATTACHMENT | COPY_SRC usage.
1305        let target_format = self.resources.target_format;
1306        let offscreen_texture = device.create_texture(&wgpu::TextureDescriptor {
1307            label: Some("offscreen_target"),
1308            size: wgpu::Extent3d {
1309                width: width.max(1),
1310                height: height.max(1),
1311                depth_or_array_layers: 1,
1312            },
1313            mip_level_count: 1,
1314            sample_count: 1,
1315            dimension: wgpu::TextureDimension::D2,
1316            format: target_format,
1317            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1318            view_formats: &[],
1319        });
1320
1321        // 2. Create a texture view for rendering into.
1322        let output_view = offscreen_texture.create_view(&wgpu::TextureViewDescriptor::default());
1323
1324        // 3. render() calls ensure_viewport_hdr which provides the depth-stencil buffer
1325        //    for both LDR and HDR paths, so no separate ensure_outline_target is needed.
1326
1327        // 4. Render the scene into the offscreen texture.
1328        //    The caller must set `frame.camera.viewport_size` to `[width as f32, height as f32]`
1329        //    and `frame.camera.render_camera.aspect` to `width as f32 / height as f32`
1330        //    for correct HDR target allocation and scissor rects.
1331        let cmd_buf = self.render(device, queue, &output_view, frame);
1332        queue.submit(std::iter::once(cmd_buf));
1333
1334        // 5. Copy texture -> staging buffer (wgpu requires row alignment to 256 bytes).
1335        let bytes_per_pixel = 4u32;
1336        let unpadded_row = width * bytes_per_pixel;
1337        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
1338        let padded_row = (unpadded_row + align - 1) & !(align - 1);
1339        let buffer_size = (padded_row * height.max(1)) as u64;
1340
1341        let staging_buf = device.create_buffer(&wgpu::BufferDescriptor {
1342            label: Some("offscreen_staging"),
1343            size: buffer_size,
1344            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1345            mapped_at_creation: false,
1346        });
1347
1348        let mut copy_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1349            label: Some("offscreen_copy_encoder"),
1350        });
1351        copy_encoder.copy_texture_to_buffer(
1352            wgpu::TexelCopyTextureInfo {
1353                texture: &offscreen_texture,
1354                mip_level: 0,
1355                origin: wgpu::Origin3d::ZERO,
1356                aspect: wgpu::TextureAspect::All,
1357            },
1358            wgpu::TexelCopyBufferInfo {
1359                buffer: &staging_buf,
1360                layout: wgpu::TexelCopyBufferLayout {
1361                    offset: 0,
1362                    bytes_per_row: Some(padded_row),
1363                    rows_per_image: Some(height.max(1)),
1364                },
1365            },
1366            wgpu::Extent3d {
1367                width: width.max(1),
1368                height: height.max(1),
1369                depth_or_array_layers: 1,
1370            },
1371        );
1372        queue.submit(std::iter::once(copy_encoder.finish()));
1373
1374        // 6. Map buffer and extract tightly-packed RGBA pixels.
1375        let (tx, rx) = std::sync::mpsc::channel();
1376        staging_buf
1377            .slice(..)
1378            .map_async(wgpu::MapMode::Read, move |result| {
1379                let _ = tx.send(result);
1380            });
1381        device
1382            .poll(wgpu::PollType::Wait {
1383                submission_index: None,
1384                timeout: Some(std::time::Duration::from_secs(5)),
1385            })
1386            .unwrap();
1387        let _ = rx.recv().unwrap_or(Err(wgpu::BufferAsyncError));
1388
1389        let mut pixels: Vec<u8> = Vec::with_capacity((width * height * 4) as usize);
1390        {
1391            let mapped = staging_buf.slice(..).get_mapped_range();
1392            let data: &[u8] = &mapped;
1393            if padded_row == unpadded_row {
1394                // No padding — copy entire slice directly.
1395                pixels.extend_from_slice(data);
1396            } else {
1397                // Strip row padding.
1398                for row in 0..height as usize {
1399                    let start = row * padded_row as usize;
1400                    let end = start + unpadded_row as usize;
1401                    pixels.extend_from_slice(&data[start..end]);
1402                }
1403            }
1404        }
1405        staging_buf.unmap();
1406
1407        // 7. Swizzle BGRA -> RGBA if the format stores bytes in BGRA order.
1408        let is_bgra = matches!(
1409            target_format,
1410            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
1411        );
1412        if is_bgra {
1413            for pixel in pixels.chunks_exact_mut(4) {
1414                pixel.swap(0, 2); // B ↔ R
1415            }
1416        }
1417
1418        pixels
1419    }
1420}