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        // Upload tone map uniform into the per-viewport buffer.
214        let mode = match pp.tone_mapping {
215            crate::renderer::ToneMapping::Reinhard => 0u32,
216            crate::renderer::ToneMapping::Aces => 1u32,
217            crate::renderer::ToneMapping::KhronosNeutral => 2u32,
218        };
219        let tm_uniform = crate::resources::ToneMapUniform {
220            exposure: pp.exposure,
221            mode,
222            bloom_enabled: if pp.bloom { 1 } else { 0 },
223            ssao_enabled: if pp.ssao { 1 } else { 0 },
224            contact_shadows_enabled: if pp.contact_shadows { 1 } else { 0 },
225            _pad_tm: [0; 3],
226        };
227        {
228            let hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
229            queue.write_buffer(
230                &hdr.tone_map_uniform_buf,
231                0,
232                bytemuck::cast_slice(&[tm_uniform]),
233            );
234
235            // Upload SSAO uniform if needed.
236            if pp.ssao {
237                let proj = frame.camera.render_camera.projection;
238                let inv_proj = proj.inverse();
239                let ssao_uniform = crate::resources::SsaoUniform {
240                    inv_proj: inv_proj.to_cols_array_2d(),
241                    proj: proj.to_cols_array_2d(),
242                    radius: 0.5,
243                    bias: 0.025,
244                    _pad: [0.0; 2],
245                };
246                queue.write_buffer(
247                    &hdr.ssao_uniform_buf,
248                    0,
249                    bytemuck::cast_slice(&[ssao_uniform]),
250                );
251            }
252
253            // Upload contact shadow uniform if needed.
254            if pp.contact_shadows {
255                let proj = frame.camera.render_camera.projection;
256                let inv_proj = proj.inverse();
257                let light_dir_world: glam::Vec3 =
258                    if let Some(l) = frame.effects.lighting.lights.first() {
259                        match l.kind {
260                            LightKind::Directional { direction } => {
261                                glam::Vec3::from(direction).normalize()
262                            }
263                            LightKind::Spot { direction, .. } => {
264                                glam::Vec3::from(direction).normalize()
265                            }
266                            _ => glam::Vec3::new(0.0, -1.0, 0.0),
267                        }
268                    } else {
269                        glam::Vec3::new(0.0, -1.0, 0.0)
270                    };
271                let view = frame.camera.render_camera.view;
272                let light_dir_view = view.transform_vector3(light_dir_world).normalize();
273                let world_up_view = view.transform_vector3(glam::Vec3::Z).normalize();
274                let cs_uniform = crate::resources::ContactShadowUniform {
275                    inv_proj: inv_proj.to_cols_array_2d(),
276                    proj: proj.to_cols_array_2d(),
277                    light_dir_view: [light_dir_view.x, light_dir_view.y, light_dir_view.z, 0.0],
278                    world_up_view: [world_up_view.x, world_up_view.y, world_up_view.z, 0.0],
279                    params: [
280                        pp.contact_shadow_max_distance,
281                        pp.contact_shadow_steps as f32,
282                        pp.contact_shadow_thickness,
283                        0.0,
284                    ],
285                };
286                queue.write_buffer(
287                    &hdr.contact_shadow_uniform_buf,
288                    0,
289                    bytemuck::cast_slice(&[cs_uniform]),
290                );
291            }
292
293            // Upload bloom uniform if needed.
294            if pp.bloom {
295                let bloom_u = crate::resources::BloomUniform {
296                    threshold: pp.bloom_threshold,
297                    intensity: pp.bloom_intensity,
298                    horizontal: 0,
299                    _pad: 0,
300                };
301                queue.write_buffer(&hdr.bloom_uniform_buf, 0, bytemuck::cast_slice(&[bloom_u]));
302            }
303        }
304
305        // Rebuild tone-map bind group with correct bloom/AO texture views.
306        {
307            let hdr = self.viewport_slots[vp_idx].hdr.as_mut().unwrap();
308            self.resources.rebuild_tone_map_bind_group(
309                device,
310                hdr,
311                pp.bloom,
312                pp.ssao,
313                pp.contact_shadows,
314            );
315        }
316
317        // -----------------------------------------------------------------------
318        // Pre-allocate OIT targets if any transparent items exist.
319        // Must happen before camera_bg is borrowed (borrow-checker constraint).
320        // -----------------------------------------------------------------------
321        {
322            let needs_oit = if self.use_instancing && !self.instanced_batches.is_empty() {
323                self.instanced_batches.iter().any(|b| b.is_transparent)
324            } else {
325                scene_items
326                    .iter()
327                    .any(|i| i.visible && i.material.opacity < 1.0)
328            };
329            if needs_oit {
330                let hdr = self.viewport_slots[vp_idx].hdr.as_mut().unwrap();
331                self.resources
332                    .ensure_viewport_oit(device, hdr, w.max(1), h.max(1));
333            }
334        }
335
336        // -----------------------------------------------------------------------
337        // Build the command encoder.
338        // -----------------------------------------------------------------------
339        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
340            label: Some("hdr_encoder"),
341        });
342
343        // Per-viewport camera bind group and HDR state for the HDR path.
344        let slot = &self.viewport_slots[vp_idx];
345        let camera_bg = &slot.camera_bind_group;
346        let slot_hdr = slot.hdr.as_ref().unwrap();
347
348        // -----------------------------------------------------------------------
349        // HDR scene pass: render geometry into the HDR texture.
350        // -----------------------------------------------------------------------
351        {
352            let hdr_view = &slot_hdr.hdr_view;
353            let hdr_depth_view = &slot_hdr.hdr_depth_view;
354
355            let clear_wgpu = wgpu::Color {
356                r: bg_color[0] as f64,
357                g: bg_color[1] as f64,
358                b: bg_color[2] as f64,
359                a: bg_color[3] as f64,
360            };
361
362            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
363                label: Some("hdr_scene_pass"),
364                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
365                    view: hdr_view,
366                    resolve_target: None,
367                    ops: wgpu::Operations {
368                        load: wgpu::LoadOp::Clear(clear_wgpu),
369                        store: wgpu::StoreOp::Store,
370                    },
371                    depth_slice: None,
372                })],
373                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
374                    view: hdr_depth_view,
375                    depth_ops: Some(wgpu::Operations {
376                        load: wgpu::LoadOp::Clear(1.0),
377                        store: wgpu::StoreOp::Store,
378                    }),
379                    stencil_ops: Some(wgpu::Operations {
380                        load: wgpu::LoadOp::Clear(0),
381                        store: wgpu::StoreOp::Store,
382                    }),
383                }),
384                timestamp_writes: None,
385                occlusion_query_set: None,
386            });
387
388            let resources = &self.resources;
389            render_pass.set_bind_group(0, camera_bg, &[]);
390
391            // Check skybox eligibility early; drawn after all opaques below.
392            let show_skybox = frame
393                .effects
394                .environment
395                .as_ref()
396                .is_some_and(|e| e.show_skybox)
397                && resources.ibl_skybox_view.is_some();
398
399            let use_instancing = self.use_instancing;
400            let batches = &self.instanced_batches;
401
402            if !scene_items.is_empty() {
403                if use_instancing && !batches.is_empty() {
404                    let excluded_items: Vec<&SceneRenderItem> = scene_items
405                        .iter()
406                        .filter(|item| {
407                            item.visible
408                                && (item.active_attribute.is_some() || item.two_sided)
409                                && resources
410                                    .mesh_store
411                                    .get(crate::resources::mesh_store::MeshId(item.mesh_index))
412                                    .is_some()
413                        })
414                        .collect();
415
416                    // Separate opaque and transparent batches.
417                    let mut opaque_batches: Vec<&InstancedBatch> = Vec::new();
418                    let mut transparent_batches: Vec<&InstancedBatch> = Vec::new();
419                    for batch in batches {
420                        if batch.is_transparent {
421                            transparent_batches.push(batch);
422                        } else {
423                            opaque_batches.push(batch);
424                        }
425                    }
426
427                    if !opaque_batches.is_empty() && !frame.viewport.wireframe_mode {
428                        if let Some(ref pipeline) = resources.hdr_solid_instanced_pipeline {
429                            render_pass.set_pipeline(pipeline);
430                            for batch in &opaque_batches {
431                                let Some(mesh) = resources
432                                    .mesh_store
433                                    .get(crate::resources::mesh_store::MeshId(batch.mesh_index))
434                                else {
435                                    continue;
436                                };
437                                let mat_key = (
438                                    batch.texture_id.unwrap_or(u64::MAX),
439                                    batch.normal_map_id.unwrap_or(u64::MAX),
440                                    batch.ao_map_id.unwrap_or(u64::MAX),
441                                );
442                                let Some(inst_tex_bg) =
443                                    resources.instance_bind_groups.get(&mat_key)
444                                else {
445                                    continue;
446                                };
447                                render_pass.set_bind_group(1, inst_tex_bg, &[]);
448                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
449                                render_pass.set_index_buffer(
450                                    mesh.index_buffer.slice(..),
451                                    wgpu::IndexFormat::Uint32,
452                                );
453                                render_pass.draw_indexed(
454                                    0..mesh.index_count,
455                                    0,
456                                    batch.instance_offset
457                                        ..batch.instance_offset + batch.instance_count,
458                                );
459                            }
460                        }
461                    }
462
463                    // NOTE: transparent_batches are now rendered in the OIT pass below,
464                    // not in the HDR scene pass. This block intentionally left empty.
465                    let _ = &transparent_batches; // suppress unused warning
466
467                    if frame.viewport.wireframe_mode {
468                        if let Some(ref hdr_wf) = resources.hdr_wireframe_pipeline {
469                            render_pass.set_pipeline(hdr_wf);
470                            for item in scene_items {
471                                if !item.visible {
472                                    continue;
473                                }
474                                let Some(mesh) = resources
475                                    .mesh_store
476                                    .get(crate::resources::mesh_store::MeshId(item.mesh_index))
477                                else {
478                                    continue;
479                                };
480                                render_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
481                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
482                                render_pass.set_index_buffer(
483                                    mesh.edge_index_buffer.slice(..),
484                                    wgpu::IndexFormat::Uint32,
485                                );
486                                render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
487                            }
488                        }
489                    } else if let (Some(hdr_solid), Some(hdr_solid_two_sided)) = (
490                        &resources.hdr_solid_pipeline,
491                        &resources.hdr_solid_two_sided_pipeline,
492                    ) {
493                        for item in excluded_items
494                            .into_iter()
495                            .filter(|item| item.material.opacity >= 1.0)
496                        {
497                            let Some(mesh) = resources
498                                .mesh_store
499                                .get(crate::resources::mesh_store::MeshId(item.mesh_index))
500                            else {
501                                continue;
502                            };
503                            let pipeline = if item.two_sided {
504                                hdr_solid_two_sided
505                            } else {
506                                hdr_solid
507                            };
508                            render_pass.set_pipeline(pipeline);
509                            render_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
510                            render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
511                            render_pass.set_index_buffer(
512                                mesh.index_buffer.slice(..),
513                                wgpu::IndexFormat::Uint32,
514                            );
515                            render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
516                        }
517                    }
518                } else {
519                    // Per-object path.
520                    let eye = glam::Vec3::from(frame.camera.render_camera.eye_position);
521                    let dist_from_eye = |item: &&SceneRenderItem| -> f32 {
522                        let pos =
523                            glam::Vec3::new(item.model[3][0], item.model[3][1], item.model[3][2]);
524                        (pos - eye).length()
525                    };
526
527                    let mut opaque: Vec<&SceneRenderItem> = Vec::new();
528                    let mut transparent: Vec<&SceneRenderItem> = Vec::new();
529                    for item in scene_items {
530                        if !item.visible
531                            || resources
532                                .mesh_store
533                                .get(crate::resources::mesh_store::MeshId(item.mesh_index))
534                                .is_none()
535                        {
536                            continue;
537                        }
538                        if item.material.opacity < 1.0 {
539                            transparent.push(item);
540                        } else {
541                            opaque.push(item);
542                        }
543                    }
544                    opaque.sort_by(|a, b| {
545                        dist_from_eye(a)
546                            .partial_cmp(&dist_from_eye(b))
547                            .unwrap_or(std::cmp::Ordering::Equal)
548                    });
549                    transparent.sort_by(|a, b| {
550                        dist_from_eye(b)
551                            .partial_cmp(&dist_from_eye(a))
552                            .unwrap_or(std::cmp::Ordering::Equal)
553                    });
554
555                    let draw_item_hdr =
556                        |render_pass: &mut wgpu::RenderPass<'_>,
557                         item: &SceneRenderItem,
558                         solid_pl: &wgpu::RenderPipeline,
559                         trans_pl: &wgpu::RenderPipeline,
560                         wf_pl: &wgpu::RenderPipeline| {
561                            let mesh = resources
562                                .mesh_store
563                                .get(crate::resources::mesh_store::MeshId(item.mesh_index))
564                                .unwrap();
565                            // mesh.object_bind_group (group 1) already carries the object uniform
566                            // and the correct texture views.
567                            render_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
568                            render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
569                            if frame.viewport.wireframe_mode {
570                                render_pass.set_pipeline(wf_pl);
571                                render_pass.set_index_buffer(
572                                    mesh.edge_index_buffer.slice(..),
573                                    wgpu::IndexFormat::Uint32,
574                                );
575                                render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
576                            } else if item.material.opacity < 1.0 {
577                                render_pass.set_pipeline(trans_pl);
578                                render_pass.set_index_buffer(
579                                    mesh.index_buffer.slice(..),
580                                    wgpu::IndexFormat::Uint32,
581                                );
582                                render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
583                            } else {
584                                render_pass.set_pipeline(solid_pl);
585                                render_pass.set_index_buffer(
586                                    mesh.index_buffer.slice(..),
587                                    wgpu::IndexFormat::Uint32,
588                                );
589                                render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
590                            }
591                        };
592
593                    // NOTE: only opaque items are drawn here. Transparent items are
594                    // routed to the OIT pass below.
595                    let _ = &transparent; // suppress unused warning
596                    if let (
597                        Some(hdr_solid),
598                        Some(hdr_solid_two_sided),
599                        Some(hdr_trans),
600                        Some(hdr_wf),
601                    ) = (
602                        &resources.hdr_solid_pipeline,
603                        &resources.hdr_solid_two_sided_pipeline,
604                        &resources.hdr_transparent_pipeline,
605                        &resources.hdr_wireframe_pipeline,
606                    ) {
607                        for item in &opaque {
608                            let solid_pl = if item.two_sided {
609                                hdr_solid_two_sided
610                            } else {
611                                hdr_solid
612                            };
613                            draw_item_hdr(&mut render_pass, item, solid_pl, hdr_trans, hdr_wf);
614                        }
615                    }
616                }
617            }
618
619            // Cap fill pass (HDR path — section view cross-section fill).
620            if !slot.cap_buffers.is_empty() {
621                if let Some(ref hdr_overlay) = resources.hdr_overlay_pipeline {
622                    render_pass.set_pipeline(hdr_overlay);
623                    render_pass.set_bind_group(0, camera_bg, &[]);
624                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.cap_buffers {
625                        render_pass.set_bind_group(1, bg, &[]);
626                        render_pass.set_vertex_buffer(0, vbuf.slice(..));
627                        render_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
628                        render_pass.draw_indexed(0..*idx_count, 0, 0..1);
629                    }
630                }
631            }
632
633            // SciVis Phase B+D+M8+M: point cloud, glyph, polyline, volume, streamtube (HDR path).
634            emit_scivis_draw_calls!(
635                &self.resources,
636                &mut render_pass,
637                &self.point_cloud_gpu_data,
638                &self.glyph_gpu_data,
639                &self.polyline_gpu_data,
640                &self.volume_gpu_data,
641                &self.streamtube_gpu_data,
642                camera_bg
643            );
644
645            // Draw skybox last among opaques — only uncovered sky pixels pass depth == 1.0.
646            if show_skybox {
647                render_pass.set_bind_group(0, camera_bg, &[]);
648                render_pass.set_pipeline(&resources.skybox_pipeline);
649                render_pass.draw(0..3, 0..1);
650            }
651        }
652
653        // -----------------------------------------------------------------------
654        // OIT pass: render transparent items into accum + reveal textures.
655        // Completely skipped when no transparent items exist (zero overhead).
656        // -----------------------------------------------------------------------
657        let has_transparent = if self.use_instancing && !self.instanced_batches.is_empty() {
658            self.instanced_batches.iter().any(|b| b.is_transparent)
659        } else {
660            scene_items
661                .iter()
662                .any(|i| i.visible && i.material.opacity < 1.0)
663        };
664
665        if has_transparent {
666            // OIT targets already allocated in the pre-pass above.
667            if let (Some(accum_view), Some(reveal_view)) = (
668                slot_hdr.oit_accum_view.as_ref(),
669                slot_hdr.oit_reveal_view.as_ref(),
670            ) {
671                let hdr_depth_view = &slot_hdr.hdr_depth_view;
672                // Clear accum to (0,0,0,0), reveal to 1.0 (no contribution yet).
673                let mut oit_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
674                    label: Some("oit_pass"),
675                    color_attachments: &[
676                        Some(wgpu::RenderPassColorAttachment {
677                            view: accum_view,
678                            resolve_target: None,
679                            ops: wgpu::Operations {
680                                load: wgpu::LoadOp::Clear(wgpu::Color {
681                                    r: 0.0,
682                                    g: 0.0,
683                                    b: 0.0,
684                                    a: 0.0,
685                                }),
686                                store: wgpu::StoreOp::Store,
687                            },
688                            depth_slice: None,
689                        }),
690                        Some(wgpu::RenderPassColorAttachment {
691                            view: reveal_view,
692                            resolve_target: None,
693                            ops: wgpu::Operations {
694                                load: wgpu::LoadOp::Clear(wgpu::Color {
695                                    r: 1.0,
696                                    g: 1.0,
697                                    b: 1.0,
698                                    a: 1.0,
699                                }),
700                                store: wgpu::StoreOp::Store,
701                            },
702                            depth_slice: None,
703                        }),
704                    ],
705                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
706                        view: hdr_depth_view,
707                        depth_ops: Some(wgpu::Operations {
708                            load: wgpu::LoadOp::Load, // reuse opaque depth
709                            store: wgpu::StoreOp::Store,
710                        }),
711                        stencil_ops: None,
712                    }),
713                    timestamp_writes: None,
714                    occlusion_query_set: None,
715                });
716
717                oit_pass.set_bind_group(0, camera_bg, &[]);
718
719                if self.use_instancing && !self.instanced_batches.is_empty() {
720                    if let Some(ref pipeline) = self.resources.oit_instanced_pipeline {
721                        oit_pass.set_pipeline(pipeline);
722                        for batch in &self.instanced_batches {
723                            if !batch.is_transparent {
724                                continue;
725                            }
726                            let Some(mesh) = self
727                                .resources
728                                .mesh_store
729                                .get(crate::resources::mesh_store::MeshId(batch.mesh_index))
730                            else {
731                                continue;
732                            };
733                            let mat_key = (
734                                batch.texture_id.unwrap_or(u64::MAX),
735                                batch.normal_map_id.unwrap_or(u64::MAX),
736                                batch.ao_map_id.unwrap_or(u64::MAX),
737                            );
738                            let Some(inst_tex_bg) =
739                                self.resources.instance_bind_groups.get(&mat_key)
740                            else {
741                                continue;
742                            };
743                            oit_pass.set_bind_group(1, inst_tex_bg, &[]);
744                            oit_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
745                            oit_pass.set_index_buffer(
746                                mesh.index_buffer.slice(..),
747                                wgpu::IndexFormat::Uint32,
748                            );
749                            oit_pass.draw_indexed(
750                                0..mesh.index_count,
751                                0,
752                                batch.instance_offset..batch.instance_offset + batch.instance_count,
753                            );
754                        }
755                    }
756                } else if let Some(ref pipeline) = self.resources.oit_pipeline {
757                    oit_pass.set_pipeline(pipeline);
758                    for item in scene_items {
759                        if !item.visible || item.material.opacity >= 1.0 {
760                            continue;
761                        }
762                        let Some(mesh) = self
763                            .resources
764                            .mesh_store
765                            .get(crate::resources::mesh_store::MeshId(item.mesh_index))
766                        else {
767                            continue;
768                        };
769                        oit_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
770                        oit_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
771                        oit_pass.set_index_buffer(
772                            mesh.index_buffer.slice(..),
773                            wgpu::IndexFormat::Uint32,
774                        );
775                        oit_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
776                    }
777                }
778            }
779        }
780
781        // -----------------------------------------------------------------------
782        // OIT composite pass: blend accum/reveal into HDR buffer.
783        // Only executes when transparent items were present.
784        // -----------------------------------------------------------------------
785        if has_transparent {
786            if let (Some(pipeline), Some(bg)) = (
787                self.resources.oit_composite_pipeline.as_ref(),
788                slot_hdr.oit_composite_bind_group.as_ref(),
789            ) {
790                let hdr_view = &slot_hdr.hdr_view;
791                let mut composite_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
792                    label: Some("oit_composite_pass"),
793                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
794                        view: hdr_view,
795                        resolve_target: None,
796                        ops: wgpu::Operations {
797                            load: wgpu::LoadOp::Load,
798                            store: wgpu::StoreOp::Store,
799                        },
800                        depth_slice: None,
801                    })],
802                    depth_stencil_attachment: None,
803                    timestamp_writes: None,
804                    occlusion_query_set: None,
805                });
806                composite_pass.set_pipeline(pipeline);
807                composite_pass.set_bind_group(0, bg, &[]);
808                composite_pass.draw(0..3, 0..1);
809            }
810        }
811
812        // -----------------------------------------------------------------------
813        // Outline composite pass (HDR path): blit offscreen outline onto hdr_view.
814        // Runs after the HDR scene pass (which has depth+stencil) in a separate
815        // pass with no depth attachment, so the composite pipeline is compatible.
816        // -----------------------------------------------------------------------
817        if !slot.outline_object_buffers.is_empty() {
818            // Prefer the HDR-format pipeline; fall back to LDR single-sample.
819            let hdr_pipeline = self
820                .resources
821                .outline_composite_pipeline_hdr
822                .as_ref()
823                .or(self.resources.outline_composite_pipeline_single.as_ref());
824            if let Some(pipeline) = hdr_pipeline {
825                let bg = &slot_hdr.outline_composite_bind_group;
826                let hdr_view = &slot_hdr.hdr_view;
827                let hdr_depth_view = &slot_hdr.hdr_depth_view;
828                let mut outline_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
829                    label: Some("hdr_outline_composite_pass"),
830                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
831                        view: hdr_view,
832                        resolve_target: None,
833                        ops: wgpu::Operations {
834                            load: wgpu::LoadOp::Load,
835                            store: wgpu::StoreOp::Store,
836                        },
837                        depth_slice: None,
838                    })],
839                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
840                        view: hdr_depth_view,
841                        depth_ops: Some(wgpu::Operations {
842                            load: wgpu::LoadOp::Load,
843                            store: wgpu::StoreOp::Discard,
844                        }),
845                        stencil_ops: None,
846                    }),
847                    timestamp_writes: None,
848                    occlusion_query_set: None,
849                });
850                outline_pass.set_pipeline(pipeline);
851                outline_pass.set_bind_group(0, bg, &[]);
852                outline_pass.draw(0..3, 0..1);
853            }
854        }
855
856        // -----------------------------------------------------------------------
857        // SSAO pass.
858        // -----------------------------------------------------------------------
859        if pp.ssao {
860            if let Some(ssao_pipeline) = &self.resources.ssao_pipeline {
861                {
862                    let mut ssao_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
863                        label: Some("ssao_pass"),
864                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
865                            view: &slot_hdr.ssao_view,
866                            resolve_target: None,
867                            ops: wgpu::Operations {
868                                load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
869                                store: wgpu::StoreOp::Store,
870                            },
871                            depth_slice: None,
872                        })],
873                        depth_stencil_attachment: None,
874                        timestamp_writes: None,
875                        occlusion_query_set: None,
876                    });
877                    ssao_pass.set_pipeline(ssao_pipeline);
878                    ssao_pass.set_bind_group(0, &slot_hdr.ssao_bg, &[]);
879                    ssao_pass.draw(0..3, 0..1);
880                }
881
882                // SSAO blur pass.
883                if let Some(ssao_blur_pipeline) = &self.resources.ssao_blur_pipeline {
884                    let mut ssao_blur_pass =
885                        encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
886                            label: Some("ssao_blur_pass"),
887                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
888                                view: &slot_hdr.ssao_blur_view,
889                                resolve_target: None,
890                                ops: wgpu::Operations {
891                                    load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
892                                    store: wgpu::StoreOp::Store,
893                                },
894                                depth_slice: None,
895                            })],
896                            depth_stencil_attachment: None,
897                            timestamp_writes: None,
898                            occlusion_query_set: None,
899                        });
900                    ssao_blur_pass.set_pipeline(ssao_blur_pipeline);
901                    ssao_blur_pass.set_bind_group(0, &slot_hdr.ssao_blur_bg, &[]);
902                    ssao_blur_pass.draw(0..3, 0..1);
903                }
904            }
905        }
906
907        // -----------------------------------------------------------------------
908        // Contact shadow pass.
909        // -----------------------------------------------------------------------
910        if pp.contact_shadows {
911            if let Some(cs_pipeline) = &self.resources.contact_shadow_pipeline {
912                let mut cs_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
913                    label: Some("contact_shadow_pass"),
914                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
915                        view: &slot_hdr.contact_shadow_view,
916                        resolve_target: None,
917                        depth_slice: None,
918                        ops: wgpu::Operations {
919                            load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
920                            store: wgpu::StoreOp::Store,
921                        },
922                    })],
923                    depth_stencil_attachment: None,
924                    timestamp_writes: None,
925                    occlusion_query_set: None,
926                });
927                cs_pass.set_pipeline(cs_pipeline);
928                cs_pass.set_bind_group(0, &slot_hdr.contact_shadow_bg, &[]);
929                cs_pass.draw(0..3, 0..1);
930            }
931        }
932
933        // -----------------------------------------------------------------------
934        // Bloom passes.
935        // -----------------------------------------------------------------------
936        if pp.bloom {
937            // Threshold pass: extract bright pixels into bloom_threshold_texture.
938            if let Some(bloom_threshold_pipeline) = &self.resources.bloom_threshold_pipeline {
939                {
940                    let mut threshold_pass =
941                        encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
942                            label: Some("bloom_threshold_pass"),
943                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
944                                view: &slot_hdr.bloom_threshold_view,
945                                resolve_target: None,
946                                ops: wgpu::Operations {
947                                    load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
948                                    store: wgpu::StoreOp::Store,
949                                },
950                                depth_slice: None,
951                            })],
952                            depth_stencil_attachment: None,
953                            timestamp_writes: None,
954                            occlusion_query_set: None,
955                        });
956                    threshold_pass.set_pipeline(bloom_threshold_pipeline);
957                    threshold_pass.set_bind_group(0, &slot_hdr.bloom_threshold_bg, &[]);
958                    threshold_pass.draw(0..3, 0..1);
959                }
960
961                // 4 ping-pong H+V blur passes for a wide glow.
962                // Pass 1: threshold -> ping -> pong. Passes 2-4: pong -> ping -> pong.
963                if let Some(blur_pipeline) = &self.resources.bloom_blur_pipeline {
964                    let blur_h_bg = &slot_hdr.bloom_blur_h_bg;
965                    let blur_h_pong_bg = &slot_hdr.bloom_blur_h_pong_bg;
966                    let blur_v_bg = &slot_hdr.bloom_blur_v_bg;
967                    let bloom_ping_view = &slot_hdr.bloom_ping_view;
968                    let bloom_pong_view = &slot_hdr.bloom_pong_view;
969                    const BLUR_ITERATIONS: usize = 4;
970                    for i in 0..BLUR_ITERATIONS {
971                        // H pass: pass 0 reads threshold, subsequent passes read pong.
972                        let h_bg = if i == 0 { blur_h_bg } else { blur_h_pong_bg };
973                        {
974                            let mut h_pass =
975                                encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
976                                    label: Some("bloom_blur_h_pass"),
977                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
978                                        view: bloom_ping_view,
979                                        resolve_target: None,
980                                        ops: wgpu::Operations {
981                                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
982                                            store: wgpu::StoreOp::Store,
983                                        },
984                                        depth_slice: None,
985                                    })],
986                                    depth_stencil_attachment: None,
987                                    timestamp_writes: None,
988                                    occlusion_query_set: None,
989                                });
990                            h_pass.set_pipeline(blur_pipeline);
991                            h_pass.set_bind_group(0, h_bg, &[]);
992                            h_pass.draw(0..3, 0..1);
993                        }
994                        // V pass: ping -> pong.
995                        {
996                            let mut v_pass =
997                                encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
998                                    label: Some("bloom_blur_v_pass"),
999                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1000                                        view: bloom_pong_view,
1001                                        resolve_target: None,
1002                                        ops: wgpu::Operations {
1003                                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1004                                            store: wgpu::StoreOp::Store,
1005                                        },
1006                                        depth_slice: None,
1007                                    })],
1008                                    depth_stencil_attachment: None,
1009                                    timestamp_writes: None,
1010                                    occlusion_query_set: None,
1011                                });
1012                            v_pass.set_pipeline(blur_pipeline);
1013                            v_pass.set_bind_group(0, blur_v_bg, &[]);
1014                            v_pass.draw(0..3, 0..1);
1015                        }
1016                    }
1017                }
1018            }
1019        }
1020
1021        // -----------------------------------------------------------------------
1022        // Tone map pass: HDR + bloom + AO -> (fxaa_texture if FXAA) or output_view.
1023        // -----------------------------------------------------------------------
1024        let use_fxaa = pp.fxaa;
1025        if let Some(tone_map_pipeline) = &self.resources.tone_map_pipeline {
1026            let tone_target: &wgpu::TextureView = if use_fxaa {
1027                &slot_hdr.fxaa_view
1028            } else {
1029                output_view
1030            };
1031            let mut tone_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1032                label: Some("tone_map_pass"),
1033                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1034                    view: tone_target,
1035                    resolve_target: None,
1036                    ops: wgpu::Operations {
1037                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1038                        store: wgpu::StoreOp::Store,
1039                    },
1040                    depth_slice: None,
1041                })],
1042                depth_stencil_attachment: None,
1043                timestamp_writes: None,
1044                occlusion_query_set: None,
1045            });
1046            tone_pass.set_pipeline(tone_map_pipeline);
1047            tone_pass.set_bind_group(0, &slot_hdr.tone_map_bind_group, &[]);
1048            tone_pass.draw(0..3, 0..1);
1049        }
1050
1051        // -----------------------------------------------------------------------
1052        // FXAA pass: fxaa_texture -> output_view (only when FXAA is enabled).
1053        // -----------------------------------------------------------------------
1054        if use_fxaa {
1055            if let Some(fxaa_pipeline) = &self.resources.fxaa_pipeline {
1056                let mut fxaa_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1057                    label: Some("fxaa_pass"),
1058                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1059                        view: output_view,
1060                        resolve_target: None,
1061                        ops: wgpu::Operations {
1062                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1063                            store: wgpu::StoreOp::Store,
1064                        },
1065                        depth_slice: None,
1066                    })],
1067                    depth_stencil_attachment: None,
1068                    timestamp_writes: None,
1069                    occlusion_query_set: None,
1070                });
1071                fxaa_pass.set_pipeline(fxaa_pipeline);
1072                fxaa_pass.set_bind_group(0, &slot_hdr.fxaa_bind_group, &[]);
1073                fxaa_pass.draw(0..3, 0..1);
1074            }
1075        }
1076
1077        encoder.finish()
1078    }
1079
1080    /// Render a frame to an offscreen texture and return raw RGBA bytes.
1081    ///
1082    /// Creates a temporary [`wgpu::Texture`] render target of the given dimensions,
1083    /// runs all render passes (shadow, scene, post-processing) into it via
1084    /// [`render()`](Self::render), then copies the result back to CPU memory.
1085    ///
1086    /// No OS window or [`wgpu::Surface`] is required. The caller is responsible for
1087    /// initialising the wgpu adapter with `compatible_surface: None` and for
1088    /// constructing a valid [`FrameData`] (including `viewport_size` matching
1089    /// `width`/`height`).
1090    ///
1091    /// Returns `width * height * 4` bytes in RGBA8 layout. The caller encodes to
1092    /// PNG/EXR independently — no image codec dependency in this crate.
1093    pub fn render_offscreen(
1094        &mut self,
1095        device: &wgpu::Device,
1096        queue: &wgpu::Queue,
1097        frame: &FrameData,
1098        width: u32,
1099        height: u32,
1100    ) -> Vec<u8> {
1101        // 1. Create offscreen texture with RENDER_ATTACHMENT | COPY_SRC usage.
1102        let target_format = self.resources.target_format;
1103        let offscreen_texture = device.create_texture(&wgpu::TextureDescriptor {
1104            label: Some("offscreen_target"),
1105            size: wgpu::Extent3d {
1106                width: width.max(1),
1107                height: height.max(1),
1108                depth_or_array_layers: 1,
1109            },
1110            mip_level_count: 1,
1111            sample_count: 1,
1112            dimension: wgpu::TextureDimension::D2,
1113            format: target_format,
1114            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1115            view_formats: &[],
1116        });
1117
1118        // 2. Create a texture view for rendering into.
1119        let output_view = offscreen_texture.create_view(&wgpu::TextureViewDescriptor::default());
1120
1121        // 3. render() calls ensure_viewport_hdr which provides the depth-stencil buffer
1122        //    for both LDR and HDR paths, so no separate ensure_outline_target is needed.
1123
1124        // 4. Render the scene into the offscreen texture.
1125        //    The caller must set `frame.camera.viewport_size` to `[width as f32, height as f32]`
1126        //    and `frame.camera.render_camera.aspect` to `width as f32 / height as f32`
1127        //    for correct HDR target allocation and scissor rects.
1128        let cmd_buf = self.render(device, queue, &output_view, frame);
1129        queue.submit(std::iter::once(cmd_buf));
1130
1131        // 5. Copy texture -> staging buffer (wgpu requires row alignment to 256 bytes).
1132        let bytes_per_pixel = 4u32;
1133        let unpadded_row = width * bytes_per_pixel;
1134        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
1135        let padded_row = (unpadded_row + align - 1) & !(align - 1);
1136        let buffer_size = (padded_row * height.max(1)) as u64;
1137
1138        let staging_buf = device.create_buffer(&wgpu::BufferDescriptor {
1139            label: Some("offscreen_staging"),
1140            size: buffer_size,
1141            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
1142            mapped_at_creation: false,
1143        });
1144
1145        let mut copy_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
1146            label: Some("offscreen_copy_encoder"),
1147        });
1148        copy_encoder.copy_texture_to_buffer(
1149            wgpu::TexelCopyTextureInfo {
1150                texture: &offscreen_texture,
1151                mip_level: 0,
1152                origin: wgpu::Origin3d::ZERO,
1153                aspect: wgpu::TextureAspect::All,
1154            },
1155            wgpu::TexelCopyBufferInfo {
1156                buffer: &staging_buf,
1157                layout: wgpu::TexelCopyBufferLayout {
1158                    offset: 0,
1159                    bytes_per_row: Some(padded_row),
1160                    rows_per_image: Some(height.max(1)),
1161                },
1162            },
1163            wgpu::Extent3d {
1164                width: width.max(1),
1165                height: height.max(1),
1166                depth_or_array_layers: 1,
1167            },
1168        );
1169        queue.submit(std::iter::once(copy_encoder.finish()));
1170
1171        // 6. Map buffer and extract tightly-packed RGBA pixels.
1172        let (tx, rx) = std::sync::mpsc::channel();
1173        staging_buf
1174            .slice(..)
1175            .map_async(wgpu::MapMode::Read, move |result| {
1176                let _ = tx.send(result);
1177            });
1178        device
1179            .poll(wgpu::PollType::Wait {
1180                submission_index: None,
1181                timeout: Some(std::time::Duration::from_secs(5)),
1182            })
1183            .unwrap();
1184        let _ = rx.recv().unwrap_or(Err(wgpu::BufferAsyncError));
1185
1186        let mut pixels: Vec<u8> = Vec::with_capacity((width * height * 4) as usize);
1187        {
1188            let mapped = staging_buf.slice(..).get_mapped_range();
1189            let data: &[u8] = &mapped;
1190            if padded_row == unpadded_row {
1191                // No padding — copy entire slice directly.
1192                pixels.extend_from_slice(data);
1193            } else {
1194                // Strip row padding.
1195                for row in 0..height as usize {
1196                    let start = row * padded_row as usize;
1197                    let end = start + unpadded_row as usize;
1198                    pixels.extend_from_slice(&data[start..end]);
1199                }
1200            }
1201        }
1202        staging_buf.unmap();
1203
1204        // 7. Swizzle BGRA -> RGBA if the format stores bytes in BGRA order.
1205        let is_bgra = matches!(
1206            target_format,
1207            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
1208        );
1209        if is_bgra {
1210            for pixel in pixels.chunks_exact_mut(4) {
1211                pixel.swap(0, 2); // B ↔ R
1212            }
1213        }
1214
1215        pixels
1216    }
1217}