Skip to main content

viewport_lib/renderer/
render.rs

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