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