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