Skip to main content

viewport_lib/renderer/render/
mod.rs

1use super::*;
2use wgpu::util::DeviceExt;
3
4/// Emit the 2D overlay draw calls shared by every paint path: SDF shapes,
5/// rects, labels, scalar bars, rulers, loading bars, and overlay images, in
6/// back-to-front order. Each block is guarded by its own prepared GPU data, so
7/// a path with no data for a given overlay skips it. Run after all scene
8/// content so the overlays sit on top. The overlay pipelines are format-neutral
9/// (no separate LDR/HDR variant), so this is shared verbatim across paths.
10macro_rules! emit_overlay_2d {
11    ($this:ident, $render_pass:ident) => {{
12        // SDF overlay shapes (drawn before rects and labels).
13        if let Some(ref sd) = $this.overlay_shape_gpu_data {
14            if sd.vertex_count > 0 {
15                if let Some(pipeline) = &$this.resources.overlay_shape_pipeline {
16                    if let Some(vbuf) = &sd.vertex_buf {
17                        $render_pass.set_pipeline(pipeline);
18                        $render_pass.set_vertex_buffer(0, vbuf.slice(..));
19                        $render_pass.draw(0..sd.vertex_count, 0..1);
20                    }
21                }
22            }
23            if !sd.tex_batches.is_empty() {
24                if let Some(pipeline) = &$this.resources.overlay_shape_tex_pipeline {
25                    $render_pass.set_pipeline(pipeline);
26                    for batch in &sd.tex_batches {
27                        $render_pass.set_bind_group(0, &batch.bind_group, &[]);
28                        $render_pass.set_vertex_buffer(0, batch.vertex_buf.slice(..));
29                        $render_pass.draw(0..batch.vertex_count, 0..1);
30                    }
31                }
32            }
33        }
34        // Overlay rects (drawn before labels so they act as backgrounds).
35        if let Some(ref rr) = $this.overlay_rect_gpu_data {
36            if let Some(pipeline) = &$this.resources.overlay_text_pipeline {
37                $render_pass.set_pipeline(pipeline);
38                $render_pass.set_bind_group(0, &rr.bind_group, &[]);
39                $render_pass.set_vertex_buffer(0, rr.vertex_buf.slice(..));
40                $render_pass.draw(0..rr.vertex_count, 0..1);
41            }
42        }
43        // Overlay labels (drawn after rects).
44        if let Some(ref ld) = $this.label_gpu_data {
45            if let Some(pipeline) = &$this.resources.overlay_text_pipeline {
46                $render_pass.set_pipeline(pipeline);
47                $render_pass.set_bind_group(0, &ld.bind_group, &[]);
48                $render_pass.set_vertex_buffer(0, ld.vertex_buf.slice(..));
49                $render_pass.draw(0..ld.vertex_count, 0..1);
50            }
51        }
52        // Scalar bars (drawn after labels).
53        if let Some(ref sb) = $this.scalar_bar_gpu_data {
54            if let Some(pipeline) = &$this.resources.overlay_text_pipeline {
55                $render_pass.set_pipeline(pipeline);
56                $render_pass.set_bind_group(0, &sb.bind_group, &[]);
57                $render_pass.set_vertex_buffer(0, sb.vertex_buf.slice(..));
58                $render_pass.draw(0..sb.vertex_count, 0..1);
59            }
60        }
61        // Rulers (drawn after scalar bars).
62        if let Some(ref rd) = $this.ruler_gpu_data {
63            if let Some(pipeline) = &$this.resources.overlay_text_pipeline {
64                $render_pass.set_pipeline(pipeline);
65                $render_pass.set_bind_group(0, &rd.bind_group, &[]);
66                $render_pass.set_vertex_buffer(0, rd.vertex_buf.slice(..));
67                $render_pass.draw(0..rd.vertex_count, 0..1);
68            }
69        }
70        // Loading bars (drawn after rulers).
71        if let Some(ref lb) = $this.loading_bar_gpu_data {
72            if let Some(pipeline) = &$this.resources.overlay_text_pipeline {
73                $render_pass.set_pipeline(pipeline);
74                $render_pass.set_bind_group(0, &lb.bind_group, &[]);
75                $render_pass.set_vertex_buffer(0, lb.vertex_buf.slice(..));
76                $render_pass.draw(0..lb.vertex_count, 0..1);
77            }
78        }
79        // Overlay images (drawn last, no depth test).
80        if !$this.overlay_image_gpu_data.is_empty() {
81            if let Some(pipeline) = &$this.resources.screen_image_pipeline {
82                $render_pass.set_pipeline(pipeline);
83                for gpu in &$this.overlay_image_gpu_data {
84                    $render_pass.set_bind_group(0, &gpu.bind_group, &[]);
85                    $render_pass.draw(0..6, 0..1);
86                }
87            }
88        }
89    }};
90}
91
92mod hdr_path;
93mod ldr_path;
94mod paint_direct;
95
96impl ViewportRenderer {
97    /// Render the scene into an intermediate dyn-res texture for the LDR callback
98    /// render path (e.g. eframe's `CallbackTrait`).
99    ///
100    /// Call from `CallbackTrait::prepare` after [`prepare`](Self::prepare), passing the
101    /// `egui_encoder`. If `current_render_scale < 1.0`, the full scene is drawn into a
102    /// scaled intermediate texture and `true` is returned. Call
103    /// [`paint_dyn_res_blit`](Self::paint_dyn_res_blit) from `CallbackTrait::paint`
104    /// instead of [`paint`](Self::paint).
105    ///
106    /// If scale is 1.0 or above, nothing is encoded and `false` is returned. Call
107    /// [`paint`](Self::paint) as normal.
108    ///
109    /// The `egui_encoder` is submitted before the surface render pass begins, so the
110    /// intermediate texture is fully written before the blit reads it.
111    pub(crate) fn prepare_ldr_dyn_res(
112        &mut self,
113        encoder: &mut wgpu::CommandEncoder,
114        device: &wgpu::Device,
115        frame: &FrameData,
116    ) -> bool {
117        if self.current_render_scale >= 1.0 - 0.001 {
118            return false;
119        }
120
121        let vp_idx = frame.camera.viewport_index;
122        let w = (frame.camera.viewport_size[0] as u32).max(1);
123        let h = (frame.camera.viewport_size[1] as u32).max(1);
124        let sw = ((w as f32 * self.current_render_scale) as u32).max(1);
125        let sh = ((h as f32 * self.current_render_scale) as u32).max(1);
126
127        self.ensure_dyn_res_target(device, vp_idx, [sw, sh], [w, h]);
128        self.resources.ensure_dyn_res_ds_pipeline(device);
129
130        let bg_colour = frame.viewport.background_colour.unwrap_or([
131            65.0 / 255.0,
132            65.0 / 255.0,
133            65.0 / 255.0,
134            1.0,
135        ]);
136
137        {
138            let slot = &self.viewport_slots[vp_idx];
139            let dr = slot.dyn_res.as_ref().unwrap();
140            let colour_view = &dr.colour_view;
141            let depth_view = &dr.depth_view;
142            let camera_bg = &slot.camera_bind_group;
143            let grid_bg = &slot.grid_bind_group;
144
145            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
146                label: Some("ldr_dyn_res_render_pass"),
147                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
148                    view: colour_view,
149                    resolve_target: None,
150                    ops: wgpu::Operations {
151                        load: wgpu::LoadOp::Clear(wgpu::Color {
152                            r: bg_colour[0] as f64,
153                            g: bg_colour[1] as f64,
154                            b: bg_colour[2] as f64,
155                            a: bg_colour[3] as f64,
156                        }),
157                        store: wgpu::StoreOp::Store,
158                    },
159                    depth_slice: None,
160                })],
161                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
162                    view: depth_view,
163                    depth_ops: Some(wgpu::Operations {
164                        load: wgpu::LoadOp::Clear(1.0),
165                        store: wgpu::StoreOp::Discard,
166                    }),
167                    stencil_ops: None,
168                }),
169                timestamp_writes: None,
170                occlusion_query_set: None,
171            });
172            emit_draw_calls!(
173                &self.resources,
174                &mut render_pass,
175                frame,
176                self.instancing.use_instancing,
177                &self.instancing.batches,
178                camera_bg,
179                grid_bg,
180                &self.compute_filter_results,
181                Some(slot),
182                &self.mesh_uniforms.wireframe_bind_groups,
183                &self.mesh_uniforms.bind_groups
184            );
185            emit_scivis_draw_calls!(
186                &self.resources,
187                &mut render_pass,
188                &self.point_cloud_gpu_data,
189                &self.glyph_gpu_data,
190                &self.polyline_gpu_data,
191                &self.volume_gpu_data,
192                &self.streamtube_gpu_data,
193                camera_bg,
194                &self.tube_gpu_data,
195                &self.image_slice_gpu_data,
196                &self.tensor_glyph_gpu_data,
197                &self.ribbon_gpu_data,
198                &self.volume_surface_slice_gpu_data,
199                &self.sprite_gpu_data,
200                &self.mesh_instance_gpu_data,
201                false
202            );
203            // TransparentVolumeMesh boundary wireframe overlay.
204            if !self.mesh_uniforms.tvm_wireframe_draws.is_empty() {
205                if let Some(ref tvm_bg) = self.mesh_uniforms.tvm_wireframe_bg {
206                    render_pass.set_bind_group(0, camera_bg, &[]);
207                    for mesh_id in &self.mesh_uniforms.tvm_wireframe_draws {
208                        if let Some(mesh) = self.resources.mesh_store.get(*mesh_id) {
209                            render_pass.set_pipeline(&self.resources.wireframe_pipeline);
210                            render_pass.set_bind_group(
211                                2,
212                                &self.resources.deform.dummy_bind_group,
213                                &[],
214                            );
215                            render_pass.set_bind_group(1, tvm_bg, &[]);
216                            render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
217                            render_pass.set_index_buffer(
218                                mesh.edge_index_buffer.slice(..),
219                                wgpu::IndexFormat::Uint32,
220                            );
221                            render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
222                        }
223                    }
224                }
225            }
226            // Implicit surface.
227            if !self.implicit_gpu_data.is_empty() {
228                if let Some(ref dual) = self.resources.implicit_pipeline {
229                    render_pass.set_pipeline(dual.for_format(false));
230                    render_pass.set_bind_group(0, camera_bg, &[]);
231                    for gpu in &self.implicit_gpu_data {
232                        render_pass.set_bind_group(1, &gpu.bind_group, &[]);
233                        render_pass.draw(0..6, 0..1);
234                    }
235                }
236            }
237            // GPU marching cubes indirect draw.
238            if !self.mc_gpu_data.is_empty() {
239                if let Some(ref dual) = self.resources.mc_surface_pipeline {
240                    render_pass.set_pipeline(dual.for_format(false));
241                    render_pass.set_bind_group(0, camera_bg, &[]);
242                    for mc in &self.mc_gpu_data {
243                        let vol = &self.resources.mc_volumes[mc.volume_idx];
244                        render_pass.set_bind_group(1, &mc.render_bg, &[]);
245                        for slab in &vol.slabs {
246                            render_pass.set_vertex_buffer(0, slab.vertex_buf.slice(..));
247                            render_pass.draw_indirect(&slab.indirect_buf, 0);
248                        }
249                    }
250                }
251            }
252            // Outline composite after all scene content.
253            emit_outline_composite!(&self.resources, &mut render_pass, Some(slot));
254            // Sub-object highlight (LDR path).
255            if let Some(sub_hl) = slot.sub_highlight.as_ref() {
256                if let (Some(fill_pl), Some(edge_pl), Some(sprite_pl)) = (
257                    &self.resources.sub_highlight_fill_ldr_pipeline,
258                    &self.resources.sub_highlight_edge_ldr_pipeline,
259                    &self.resources.sub_highlight_sprite_ldr_pipeline,
260                ) {
261                    if sub_hl.fill_vertex_count > 0 {
262                        render_pass.set_pipeline(fill_pl);
263                        render_pass.set_bind_group(0, camera_bg, &[]);
264                        render_pass.set_bind_group(1, &sub_hl.fill_bind_group, &[]);
265                        render_pass.set_vertex_buffer(0, sub_hl.fill_vertex_buf.slice(..));
266                        render_pass.draw(0..sub_hl.fill_vertex_count, 0..1);
267                    }
268                    if sub_hl.edge_segment_count > 0 {
269                        render_pass.set_pipeline(edge_pl);
270                        render_pass.set_bind_group(0, camera_bg, &[]);
271                        render_pass.set_bind_group(1, &sub_hl.edge_bind_group, &[]);
272                        render_pass.set_vertex_buffer(0, sub_hl.edge_vertex_buf.slice(..));
273                        render_pass.draw(0..6, 0..sub_hl.edge_segment_count);
274                    }
275                    if sub_hl.sprite_point_count > 0 {
276                        render_pass.set_pipeline(sprite_pl);
277                        render_pass.set_bind_group(0, camera_bg, &[]);
278                        render_pass.set_bind_group(1, &sub_hl.sprite_bind_group, &[]);
279                        render_pass.set_vertex_buffer(0, sub_hl.sprite_vertex_buf.slice(..));
280                        render_pass.draw(0..6, 0..sub_hl.sprite_point_count);
281                    }
282                }
283            }
284            // Screen-space image overlays.
285            if !self.screen_image_gpu_data.is_empty() {
286                if let Some(pipeline) = &self.resources.screen_image_pipeline {
287                    render_pass.set_pipeline(pipeline);
288                    for gpu in &self.screen_image_gpu_data {
289                        render_pass.set_bind_group(0, &gpu.bind_group, &[]);
290                        render_pass.draw(0..6, 0..1);
291                    }
292                }
293            }
294            emit_overlay_2d!(self, render_pass);
295        }
296
297        true
298    }
299
300    /// Blit the dyn-res intermediate texture into the provided render pass.
301    ///
302    /// Call from `CallbackTrait::paint` when
303    /// [`prepare_ldr_dyn_res`](Self::prepare_ldr_dyn_res) returned `true` for the same
304    /// frame. Emits a fullscreen upscale quad into `render_pass`.
305    pub(crate) fn paint_dyn_res_blit<'rp>(
306        &self,
307        render_pass: &mut wgpu::RenderPass<'rp>,
308        frame: &FrameData,
309    ) {
310        let vp_idx = frame.camera.viewport_index;
311        if let Some(dr) = self
312            .viewport_slots
313            .get(vp_idx)
314            .and_then(|s| s.dyn_res.as_ref())
315        {
316            if let Some(pipeline) = &self.resources.dyn_res_upscale_ds_pipeline {
317                render_pass.set_pipeline(pipeline);
318                render_pass.set_bind_group(0, &dr.upscale_bind_group, &[]);
319                render_pass.draw(0..3, 0..1);
320            }
321        }
322    }
323
324    /// Run the full HDR pipeline (OIT, EDL, tone-map) for the eframe callback model.
325    ///
326    /// This is the HDR counterpart of
327    /// [`prepare_ldr_dyn_res`](Self::prepare_ldr_dyn_res) for use when
328    /// `frame.effects.post_process.enabled` is `true`.
329    ///
330    /// Internally this method:
331    /// 1. Calls [`prepare`](Self::prepare) to upload uniforms and run the shadow pass.
332    /// 2. Ensures a per-viewport intermediate texture at the viewport's native resolution.
333    /// 3. Calls the full render pipeline (including OIT and EDL) into that texture.
334    ///
335    /// The returned [`wgpu::CommandBuffer`] must be returned from
336    /// `CallbackTrait::prepare` so eframe submits it **before** the egui render pass.
337    ///
338    /// Call [`paint_hdr_blit`](Self::paint_hdr_blit) from `CallbackTrait::paint` to
339    /// composite the intermediate texture into the egui render pass.
340    pub(crate) fn prepare_hdr_callback(
341        &mut self,
342        device: &wgpu::Device,
343        queue: &wgpu::Queue,
344        frame: &FrameData,
345    ) -> wgpu::CommandBuffer {
346        self.prepare(device, queue, frame);
347
348        let vp_idx = frame.camera.viewport_index;
349        // Intermediate texture must be at physical pixel size so it matches the
350        // HDR depth buffer allocated inside render_frame_internal (which also
351        // uses physical pixels). Using logical size here produces a mismatch on
352        // hidpi displays between the colour attachment (this texture) and the
353        // depth attachment (hdr_depth_view) in the grid/overlay passes.
354        let ppp = frame.camera.pixels_per_point;
355        let w = (frame.camera.viewport_size[0] * ppp).round() as u32;
356        let h = (frame.camera.viewport_size[1] * ppp).round() as u32;
357
358        // Ensure the blit pipeline (required by create_hdr_callback_target).
359        self.resources.ensure_dyn_res_pipeline(device);
360        self.resources.ensure_dyn_res_ds_pipeline(device);
361
362        // Create or resize the per-viewport intermediate texture.
363        self.ensure_viewport_slot(device, vp_idx);
364        let needs_create = match self.viewport_slots[vp_idx].hdr_callback.as_ref() {
365            None => true,
366            Some(t) => t.size != [w, h],
367        };
368        if needs_create {
369            let target = self.resources.create_hdr_callback_target(device, [w, h]);
370            self.viewport_slots[vp_idx].hdr_callback = Some(target);
371        }
372
373        // Create a fresh TextureView from the stored Texture.
374        // This owned view does not borrow viewport_slots, allowing the subsequent
375        // mutable call to render_frame_internal without a borrow conflict.
376        let output_view = self.viewport_slots[vp_idx]
377            .hdr_callback
378            .as_ref()
379            .unwrap()
380            .texture
381            .create_view(&wgpu::TextureViewDescriptor::default());
382
383        self.render_frame_internal(device, queue, &output_view, vp_idx, frame)
384    }
385
386    /// HDR encode for a single viewport in the multi-viewport eframe callback model.
387    ///
388    /// Like [`prepare_hdr_callback`](Self::prepare_hdr_callback) but skips the internal
389    /// [`prepare`](Self::prepare) call. The caller must have already called
390    /// [`prepare_scene`](Self::prepare_scene) and [`prepare_viewport`](Self::prepare_viewport)
391    /// for `id` before invoking this.
392    ///
393    /// Multi-viewport HDR sequence:
394    /// 1. Call `prepare_scene` once.
395    /// 2. Call `prepare_viewport` for each viewport.
396    /// 3. Call this method for each viewport; collect the returned `CommandBuffer`s.
397    /// 4. Return them from `CallbackTrait::prepare`.
398    ///
399    /// Call [`paint_hdr_blit`](Self::paint_hdr_blit) for each viewport from
400    /// `CallbackTrait::paint` with the scissor/viewport rect set first.
401    pub(crate) fn prepare_hdr_callback_viewport(
402        &mut self,
403        device: &wgpu::Device,
404        queue: &wgpu::Queue,
405        id: ViewportId,
406        frame: &FrameData,
407    ) -> wgpu::CommandBuffer {
408        let vp_idx = id.0;
409        let ppp = frame.camera.pixels_per_point;
410        let w = (frame.camera.viewport_size[0] * ppp).round() as u32;
411        let h = (frame.camera.viewport_size[1] * ppp).round() as u32;
412
413        self.resources.ensure_dyn_res_pipeline(device);
414        self.resources.ensure_dyn_res_ds_pipeline(device);
415
416        self.ensure_viewport_slot(device, vp_idx);
417        let needs_create = match self.viewport_slots[vp_idx].hdr_callback.as_ref() {
418            None => true,
419            Some(t) => t.size != [w, h],
420        };
421        if needs_create {
422            let target = self.resources.create_hdr_callback_target(device, [w, h]);
423            self.viewport_slots[vp_idx].hdr_callback = Some(target);
424        }
425
426        let output_view = self.viewport_slots[vp_idx]
427            .hdr_callback
428            .as_ref()
429            .unwrap()
430            .texture
431            .create_view(&wgpu::TextureViewDescriptor::default());
432
433        self.render_frame_internal(device, queue, &output_view, vp_idx, frame)
434    }
435
436    /// Blit the HDR intermediate texture into the egui render pass.
437    ///
438    /// Call from `CallbackTrait::paint` after
439    /// [`prepare_hdr_callback`](Self::prepare_hdr_callback) has been called for the
440    /// same frame and viewport. Emits a fullscreen triangle into `render_pass`.
441    pub(crate) fn paint_hdr_blit<'rp>(
442        &self,
443        render_pass: &mut wgpu::RenderPass<'rp>,
444        frame: &FrameData,
445    ) {
446        let vp_idx = frame.camera.viewport_index;
447        if let Some(hc) = self
448            .viewport_slots
449            .get(vp_idx)
450            .and_then(|s| s.hdr_callback.as_ref())
451        {
452            if let Some(pipeline) = &self.resources.dyn_res_upscale_ds_pipeline {
453                render_pass.set_pipeline(pipeline);
454                render_pass.set_bind_group(0, &hc.blit_bind_group, &[]);
455                render_pass.draw(0..3, 0..1);
456            }
457        }
458        // Shadow atlas viewer overlay.
459        if frame.effects.show_shadow_atlas {
460            render_pass.set_pipeline(&self.resources.shadow_atlas_viewer_pipeline);
461            render_pass.set_bind_group(0, &self.resources.shadow_atlas_viewer_bg, &[]);
462            render_pass.draw(0..6, 0..1);
463        }
464    }
465
466    /// Like [`paint_hdr_blit`](Self::paint_hdr_blit) but for render passes without a
467    /// depth-stencil attachment. Use this when you create the blit render pass yourself
468    /// (e.g. winit) and omit the depth attachment.
469    pub(crate) fn paint_hdr_blit_no_ds<'rp>(
470        &self,
471        render_pass: &mut wgpu::RenderPass<'rp>,
472        frame: &FrameData,
473    ) {
474        let vp_idx = frame.camera.viewport_index;
475        if let Some(hc) = self
476            .viewport_slots
477            .get(vp_idx)
478            .and_then(|s| s.hdr_callback.as_ref())
479        {
480            if let Some(pipeline) = &self.resources.dyn_res_upscale_pipeline {
481                render_pass.set_pipeline(pipeline);
482                render_pass.set_bind_group(0, &hc.blit_bind_group, &[]);
483                render_pass.draw(0..3, 0..1);
484            }
485        }
486    }
487
488    /// Unified prepare step for the eframe `CallbackTrait::prepare` method.
489    ///
490    /// Replaces manual `prepare` + `prepare_ldr_dyn_res` or `prepare_hdr_callback`
491    /// calls. Dispatches internally based on `frame.effects.post_process.enabled`:
492    ///
493    /// - HDR path (`post_process.enabled = true`): runs the full HDR pipeline (OIT,
494    ///   EDL, tone-map) and returns the resulting `CommandBuffer` for eframe to
495    ///   submit before the egui render pass.
496    /// - LDR path: calls `prepare`, and if dynamic resolution is active, encodes the
497    ///   scene into a separate `CommandBuffer` (also submitted before the render
498    ///   pass). Returns an empty `Vec` when dyn-res is inactive.
499    ///
500    /// Call [`paint_callback`](Self::paint_callback) from `CallbackTrait::paint`.
501    pub(crate) fn prepare_callback(
502        &mut self,
503        device: &wgpu::Device,
504        queue: &wgpu::Queue,
505        frame: &FrameData,
506    ) -> Vec<wgpu::CommandBuffer> {
507        if frame.effects.post_process.enabled {
508            let cb = self.prepare_hdr_callback(device, queue, frame);
509            vec![cb]
510        } else {
511            self.prepare(device, queue, frame);
512            if self.current_render_scale < 1.0 - 0.001 {
513                let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
514                    label: Some("ldr_dyn_res_callback_encoder"),
515                });
516                self.prepare_ldr_dyn_res(&mut encoder, device, frame);
517                vec![encoder.finish()]
518            } else {
519                Vec::new()
520            }
521        }
522    }
523
524    /// Unified paint step for the eframe `CallbackTrait::paint` method.
525    ///
526    /// Call after [`prepare_callback`](Self::prepare_callback) for the same frame.
527    /// Dispatches internally to `paint_hdr_blit`, `paint_dyn_res_blit`, or `paint`
528    /// based on which path `prepare_callback` activated.
529    pub(crate) fn paint_callback<'rp>(
530        &self,
531        render_pass: &mut wgpu::RenderPass<'rp>,
532        frame: &FrameData,
533    ) {
534        let vp_idx = frame.camera.viewport_index;
535        if frame.effects.post_process.enabled {
536            if self
537                .viewport_slots
538                .get(vp_idx)
539                .and_then(|s| s.hdr_callback.as_ref())
540                .is_some()
541            {
542                self.paint_hdr_blit(render_pass, frame);
543                return;
544            }
545        }
546        if self.current_render_scale < 1.0 - 0.001
547            && self
548                .viewport_slots
549                .get(vp_idx)
550                .and_then(|s| s.dyn_res.as_ref())
551                .is_some()
552        {
553            self.paint_dyn_res_blit(render_pass, frame);
554        } else {
555            self.paint_to(render_pass, frame);
556        }
557    }
558
559    /// High-level HDR render for a single viewport identified by `id`.
560    ///
561    /// Unlike [`render`](Self::render), this method does **not** call
562    /// [`prepare`](Self::prepare) internally.  The caller must have already called
563    /// [`prepare_scene`](Self::prepare_scene) and
564    /// [`prepare_viewport`](Self::prepare_viewport) for `id` before invoking this.
565    ///
566    /// This is the right entry point for multi-viewport frames:
567    /// 1. Call `prepare_scene` once.
568    /// 2. Call `prepare_viewport` for each viewport.
569    /// 3. Call `render_viewport` for each viewport with its own `output_view`.
570    ///
571    /// Returns a [`wgpu::CommandBuffer`] ready to submit.
572    pub(crate) fn render_viewport(
573        &mut self,
574        device: &wgpu::Device,
575        queue: &wgpu::Queue,
576        output_view: &wgpu::TextureView,
577        id: ViewportId,
578        frame: &FrameData,
579    ) -> wgpu::CommandBuffer {
580        self.render_frame_internal(device, queue, output_view, id.0, frame)
581    }
582
583    /// High-level HDR render method. Handles the full post-processing pipeline:
584    /// scene -> HDR texture -> (bloom) -> (SSAO) -> tone map -> output_view.
585    ///
586    /// When `frame.post_process.enabled` is false, falls back to a simple LDR render
587    /// pass targeting `output_view` directly.
588    ///
589    /// Returns a `CommandBuffer` ready to submit.
590    pub(crate) fn render(
591        &mut self,
592        device: &wgpu::Device,
593        queue: &wgpu::Queue,
594        output_view: &wgpu::TextureView,
595        frame: &FrameData,
596    ) -> wgpu::CommandBuffer {
597        // Always run prepare() to upload uniforms and run the shadow pass.
598        self.prepare(device, queue, frame);
599        self.render_frame_internal(
600            device,
601            queue,
602            output_view,
603            frame.camera.viewport_index,
604            frame,
605        )
606    }
607
608    /// Render-only path shared by `render()` and `render_viewport()`.
609    ///
610    /// `vp_idx` selects the per-viewport slot to use for camera/HDR state,
611    /// independent of `frame.camera.viewport_index`.
612    fn render_frame_internal(
613        &mut self,
614        device: &wgpu::Device,
615        queue: &wgpu::Queue,
616        output_view: &wgpu::TextureView,
617        vp_idx: usize,
618        frame: &FrameData,
619    ) -> wgpu::CommandBuffer {
620        let paint_start = std::time::Instant::now();
621        // Read scene items from the surface submission, then extend with the
622        // boundary draws contributed by opaque volume meshes (see the matching
623        // construction in `prepare.rs`).
624        let scene_items_owned: Vec<SceneRenderItem> = {
625            let surfaces = match &frame.scene.surfaces {
626                SurfaceSubmission::Flat(items) => items.as_ref(),
627            };
628            let extra = frame
629                .scene
630                .volume_meshes
631                .iter()
632                .filter(|item| item.transparency.is_none())
633                .map(|item| item.to_render_item());
634            surfaces.iter().cloned().chain(extra).collect()
635        };
636        let scene_items: &[SceneRenderItem] = &scene_items_owned;
637
638        let bg_colour = frame.viewport.background_colour.unwrap_or([
639            65.0 / 255.0,
640            65.0 / 255.0,
641            65.0 / 255.0,
642            1.0,
643        ]);
644        let ppp = frame.camera.pixels_per_point;
645        let w = (frame.camera.viewport_size[0] * ppp).round() as u32;
646        let h = (frame.camera.viewport_size[1] * ppp).round() as u32;
647
648        // Ensure per-viewport HDR targets. Provides a depth buffer for both LDR and HDR paths.
649        let ssaa_factor = frame.effects.post_process.ssaa_factor.max(1);
650        self.ensure_viewport_hdr(
651            device,
652            queue,
653            vp_idx,
654            w.max(1),
655            h.max(1),
656            ssaa_factor,
657            self.current_render_scale,
658        );
659
660        // Lazy-initialize GPU timestamp resources on first render call when supported.
661        if self.ts_query_set.is_none()
662            && device.features().contains(wgpu::Features::TIMESTAMP_QUERY)
663        {
664            // One begin/end timestamp pair per measured pass.
665            let ts_count = 2 * super::GPU_TS_SLOTS;
666            let ts_bytes = ts_count as u64 * 8;
667            self.ts_query_set = Some(device.create_query_set(&wgpu::QuerySetDescriptor {
668                label: Some("ts_query_set"),
669                ty: wgpu::QueryType::Timestamp,
670                count: ts_count,
671            }));
672            self.ts_resolve_buf = Some(device.create_buffer(&wgpu::BufferDescriptor {
673                label: Some("ts_resolve_buf"),
674                size: ts_bytes,
675                usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
676                mapped_at_creation: false,
677            }));
678            self.ts_staging_buf = Some(device.create_buffer(&wgpu::BufferDescriptor {
679                label: Some("ts_staging_buf"),
680                size: ts_bytes,
681                usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
682                mapped_at_creation: false,
683            }));
684            self.ts_period = queue.get_timestamp_period();
685        }
686
687        let cmd_buf = if !frame.effects.post_process.enabled {
688            self.render_frame_ldr(device, queue, output_view, vp_idx, frame, bg_colour, w, h)
689        } else {
690            self.render_frame_hdr(
691                device,
692                queue,
693                output_view,
694                vp_idx,
695                frame,
696                scene_items,
697                bg_colour,
698                w,
699                h,
700                ssaa_factor,
701            )
702        };
703        // CPU time spent encoding the paint pass (draw-call recording), separate
704        // from prepare. Latched so last_frame_stats() reflects it after render.
705        self.last_stats.cpu_paint_ms = paint_start.elapsed().as_secs_f32() * 1000.0;
706        cmd_buf
707    }
708
709    /// Render a frame to an offscreen texture and return raw RGBA bytes.
710    ///
711    /// Creates a temporary [`wgpu::Texture`] render target of the given dimensions,
712    /// runs all render passes (shadow, scene, post-processing) into it via
713    /// [`render()`](Self::render), then copies the result back to CPU memory.
714    ///
715    /// No OS window or [`wgpu::Surface`] is required. The caller is responsible for
716    /// initialising the wgpu adapter with `compatible_surface: None` and for
717    /// constructing a valid [`FrameData`] (including `viewport_size` matching
718    /// `width`/`height`).
719    ///
720    /// Returns `width * height * 4` bytes in RGBA8 layout. The caller encodes to
721    /// PNG/EXR independently : no image codec dependency in this crate.
722    pub fn render_offscreen(
723        &mut self,
724        device: &wgpu::Device,
725        queue: &wgpu::Queue,
726        frame: &FrameData,
727        width: u32,
728        height: u32,
729    ) -> Vec<u8> {
730        // 1. Create offscreen texture with RENDER_ATTACHMENT | COPY_SRC usage.
731        let target_format = self.resources.target_format;
732        let offscreen_texture = device.create_texture(&wgpu::TextureDescriptor {
733            label: Some("offscreen_target"),
734            size: wgpu::Extent3d {
735                width: width.max(1),
736                height: height.max(1),
737                depth_or_array_layers: 1,
738            },
739            mip_level_count: 1,
740            sample_count: 1,
741            dimension: wgpu::TextureDimension::D2,
742            format: target_format,
743            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
744            view_formats: &[],
745        });
746
747        // 2. Create a texture view for rendering into.
748        let output_view = offscreen_texture.create_view(&wgpu::TextureViewDescriptor::default());
749
750        // 3. render() calls ensure_viewport_hdr which provides the depth-stencil buffer
751        //    for both LDR and HDR paths, so no separate ensure_outline_target is needed.
752
753        // 4. Render the scene into the offscreen texture.
754        //    The caller must set `frame.camera.viewport_size` to `[width as f32, height as f32]`
755        //    and `frame.camera.render_camera.aspect` to `width as f32 / height as f32`
756        //    for correct HDR target allocation and scissor rects.
757        let cmd_buf = self.render(device, queue, &output_view, frame);
758        queue.submit(std::iter::once(cmd_buf));
759
760        // 5. Copy texture -> staging buffer (wgpu requires row alignment to 256 bytes).
761        let bytes_per_pixel = 4u32;
762        let unpadded_row = width * bytes_per_pixel;
763        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
764        let padded_row = (unpadded_row + align - 1) & !(align - 1);
765        let buffer_size = (padded_row * height.max(1)) as u64;
766
767        let staging_buf = device.create_buffer(&wgpu::BufferDescriptor {
768            label: Some("offscreen_staging"),
769            size: buffer_size,
770            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
771            mapped_at_creation: false,
772        });
773
774        let mut copy_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
775            label: Some("offscreen_copy_encoder"),
776        });
777        copy_encoder.copy_texture_to_buffer(
778            wgpu::TexelCopyTextureInfo {
779                texture: &offscreen_texture,
780                mip_level: 0,
781                origin: wgpu::Origin3d::ZERO,
782                aspect: wgpu::TextureAspect::All,
783            },
784            wgpu::TexelCopyBufferInfo {
785                buffer: &staging_buf,
786                layout: wgpu::TexelCopyBufferLayout {
787                    offset: 0,
788                    bytes_per_row: Some(padded_row),
789                    rows_per_image: Some(height.max(1)),
790                },
791            },
792            wgpu::Extent3d {
793                width: width.max(1),
794                height: height.max(1),
795                depth_or_array_layers: 1,
796            },
797        );
798        queue.submit(std::iter::once(copy_encoder.finish()));
799
800        // 6. Map buffer and extract tightly-packed RGBA pixels.
801        let (tx, rx) = std::sync::mpsc::channel();
802        staging_buf
803            .slice(..)
804            .map_async(wgpu::MapMode::Read, move |result| {
805                let _ = tx.send(result);
806            });
807        device
808            .poll(wgpu::PollType::Wait {
809                submission_index: None,
810                timeout: Some(std::time::Duration::from_secs(5)),
811            })
812            .unwrap();
813        let _ = rx.recv().unwrap_or(Err(wgpu::BufferAsyncError));
814
815        let mut pixels: Vec<u8> = Vec::with_capacity((width * height * 4) as usize);
816        {
817            let mapped = staging_buf.slice(..).get_mapped_range();
818            let data: &[u8] = &mapped;
819            if padded_row == unpadded_row {
820                // No padding : copy entire slice directly.
821                pixels.extend_from_slice(data);
822            } else {
823                // Strip row padding.
824                for row in 0..height as usize {
825                    let start = row * padded_row as usize;
826                    let end = start + unpadded_row as usize;
827                    pixels.extend_from_slice(&data[start..end]);
828                }
829            }
830        }
831        staging_buf.unmap();
832
833        // 7. Swizzle BGRA -> RGBA if the format stores bytes in BGRA order.
834        let is_bgra = matches!(
835            target_format,
836            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
837        );
838        if is_bgra {
839            for pixel in pixels.chunks_exact_mut(4) {
840                pixel.swap(0, 2); // B <-> R
841            }
842        }
843
844        pixels
845    }
846
847    // ------------------------------------------------------------------
848    // Backdrop blur helpers
849    // ------------------------------------------------------------------
850
851    /// Ensure the backdrop blur state textures exist at the right size.
852    fn ensure_backdrop_blur_state(&mut self, device: &wgpu::Device, w: u32, h: u32) {
853        let need_recreate = match &self.backdrop_blur_state {
854            Some(s) => s.size != [w, h] || s.format != self.resources.target_format,
855            None => true,
856        };
857        if !need_recreate {
858            return;
859        }
860
861        let format = self.resources.target_format;
862        let blur_w = (w / 2).max(1);
863        let blur_h = (h / 2).max(1);
864
865        let intermediate_texture = device.create_texture(&wgpu::TextureDescriptor {
866            label: Some("backdrop_intermediate"),
867            size: wgpu::Extent3d {
868                width: w,
869                height: h,
870                depth_or_array_layers: 1,
871            },
872            mip_level_count: 1,
873            sample_count: 1,
874            dimension: wgpu::TextureDimension::D2,
875            format,
876            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
877            view_formats: &[],
878        });
879        let intermediate_view = intermediate_texture.create_view(&Default::default());
880
881        let make_blur_tex = |label: &str| {
882            let t = device.create_texture(&wgpu::TextureDescriptor {
883                label: Some(label),
884                size: wgpu::Extent3d {
885                    width: blur_w,
886                    height: blur_h,
887                    depth_or_array_layers: 1,
888                },
889                mip_level_count: 1,
890                sample_count: 1,
891                dimension: wgpu::TextureDimension::D2,
892                format,
893                usage: wgpu::TextureUsages::RENDER_ATTACHMENT
894                    | wgpu::TextureUsages::TEXTURE_BINDING,
895                view_formats: &[],
896            });
897            let v = t.create_view(&Default::default());
898            (t, v)
899        };
900        let (blur_a_texture, blur_a_view) = make_blur_tex("backdrop_blur_a");
901        let (blur_b_texture, blur_b_view) = make_blur_tex("backdrop_blur_b");
902
903        self.backdrop_blur_state = Some(crate::resources::BackdropBlurState {
904            intermediate_texture,
905            intermediate_view,
906            blur_a_texture,
907            blur_a_view,
908            blur_b_texture,
909            blur_b_view,
910            size: [w, h],
911            format,
912        });
913    }
914
915    /// Run the backdrop blur pipeline: blit scene to half-res, then H blur, then V blur.
916    /// Returns the bind group that can be used to draw blur overlay shapes with the
917    /// texture pipeline.
918    fn run_backdrop_blur(
919        &self,
920        encoder: &mut wgpu::CommandEncoder,
921        device: &wgpu::Device,
922        _queue: &wgpu::Queue,
923        source_view: &wgpu::TextureView,
924        spread: f32,
925    ) -> wgpu::BindGroup {
926        let bs = self.backdrop_blur_state.as_ref().unwrap();
927        let blur_bgl = self.resources.backdrop_blur_bgl.as_ref().unwrap();
928        let blur_sampler = self.resources.backdrop_blur_sampler.as_ref().unwrap();
929        let blur_pipeline = self.resources.backdrop_blur_pipeline.as_ref().unwrap();
930        // Reuse dyn_res blit pipeline and BGL for the downsample pass.
931        let blit_pipeline = self.resources.dyn_res_upscale_pipeline.as_ref().unwrap();
932        let blit_bgl = self.resources.dyn_res_upscale_bgl.as_ref().unwrap();
933        let blit_sampler = self.resources.dyn_res_linear_sampler.as_ref().unwrap();
934
935        // Step 1: downsample source -> blur_a (half-res) using bilinear blit.
936        let downsample_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
937            label: Some("backdrop_downsample_bg"),
938            layout: blit_bgl,
939            entries: &[
940                wgpu::BindGroupEntry {
941                    binding: 0,
942                    resource: wgpu::BindingResource::TextureView(source_view),
943                },
944                wgpu::BindGroupEntry {
945                    binding: 1,
946                    resource: wgpu::BindingResource::Sampler(blit_sampler),
947                },
948            ],
949        });
950        {
951            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
952                label: Some("backdrop_downsample"),
953                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
954                    view: &bs.blur_a_view,
955                    resolve_target: None,
956                    ops: wgpu::Operations {
957                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
958                        store: wgpu::StoreOp::Store,
959                    },
960                    depth_slice: None,
961                })],
962                depth_stencil_attachment: None,
963                timestamp_writes: None,
964                occlusion_query_set: None,
965            });
966            pass.set_pipeline(blit_pipeline);
967            pass.set_bind_group(0, &downsample_bg, &[]);
968            pass.draw(0..3, 0..1);
969        }
970
971        // Spread scaled for half-res: each texel covers 2 screen pixels.
972        let effective_spread = (spread / 2.0).max(1.0);
973
974        // Step 2: horizontal blur: blur_a -> blur_b.
975        let h_uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
976            label: Some("blur_h_uniform"),
977            contents: bytemuck::cast_slice(&[1u32, effective_spread.to_bits(), 0u32, 0u32]),
978            usage: wgpu::BufferUsages::UNIFORM,
979        });
980        let h_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
981            label: Some("blur_h_bg"),
982            layout: blur_bgl,
983            entries: &[
984                wgpu::BindGroupEntry {
985                    binding: 0,
986                    resource: wgpu::BindingResource::TextureView(&bs.blur_a_view),
987                },
988                wgpu::BindGroupEntry {
989                    binding: 1,
990                    resource: wgpu::BindingResource::Sampler(blur_sampler),
991                },
992                wgpu::BindGroupEntry {
993                    binding: 2,
994                    resource: h_uniform.as_entire_binding(),
995                },
996            ],
997        });
998        {
999            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1000                label: Some("backdrop_blur_h"),
1001                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1002                    view: &bs.blur_b_view,
1003                    resolve_target: None,
1004                    ops: wgpu::Operations {
1005                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1006                        store: wgpu::StoreOp::Store,
1007                    },
1008                    depth_slice: None,
1009                })],
1010                depth_stencil_attachment: None,
1011                timestamp_writes: None,
1012                occlusion_query_set: None,
1013            });
1014            pass.set_pipeline(blur_pipeline);
1015            pass.set_bind_group(0, &h_bg, &[]);
1016            pass.draw(0..3, 0..1);
1017        }
1018
1019        // Step 3: vertical blur: blur_b -> blur_a.
1020        let v_uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
1021            label: Some("blur_v_uniform"),
1022            contents: bytemuck::cast_slice(&[0u32, effective_spread.to_bits(), 0u32, 0u32]),
1023            usage: wgpu::BufferUsages::UNIFORM,
1024        });
1025        let v_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
1026            label: Some("blur_v_bg"),
1027            layout: blur_bgl,
1028            entries: &[
1029                wgpu::BindGroupEntry {
1030                    binding: 0,
1031                    resource: wgpu::BindingResource::TextureView(&bs.blur_b_view),
1032                },
1033                wgpu::BindGroupEntry {
1034                    binding: 1,
1035                    resource: wgpu::BindingResource::Sampler(blur_sampler),
1036                },
1037                wgpu::BindGroupEntry {
1038                    binding: 2,
1039                    resource: v_uniform.as_entire_binding(),
1040                },
1041            ],
1042        });
1043        {
1044            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1045                label: Some("backdrop_blur_v"),
1046                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1047                    view: &bs.blur_a_view,
1048                    resolve_target: None,
1049                    ops: wgpu::Operations {
1050                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1051                        store: wgpu::StoreOp::Store,
1052                    },
1053                    depth_slice: None,
1054                })],
1055                depth_stencil_attachment: None,
1056                timestamp_writes: None,
1057                occlusion_query_set: None,
1058            });
1059            pass.set_pipeline(blur_pipeline);
1060            pass.set_bind_group(0, &v_bg, &[]);
1061            pass.draw(0..3, 0..1);
1062        }
1063
1064        // Build the bind group for overlay shape drawing. Uses the overlay_shape_tex
1065        // bind group layout (texture + sampler) so blur shapes can be drawn with the
1066        // existing texture pipeline.
1067        let tex_bgl = self.resources.overlay_shape_tex_bgl.as_ref().unwrap();
1068        let tex_sampler = self.resources.overlay_shape_tex_sampler.as_ref().unwrap();
1069        device.create_bind_group(&wgpu::BindGroupDescriptor {
1070            label: Some("backdrop_blur_overlay_bg"),
1071            layout: tex_bgl,
1072            entries: &[
1073                wgpu::BindGroupEntry {
1074                    binding: 0,
1075                    resource: wgpu::BindingResource::TextureView(&bs.blur_a_view),
1076                },
1077                wgpu::BindGroupEntry {
1078                    binding: 1,
1079                    resource: wgpu::BindingResource::Sampler(tex_sampler),
1080                },
1081            ],
1082        })
1083    }
1084
1085    /// Returns true if the current frame has overlay shapes that need backdrop blur.
1086    fn has_backdrop_blur_shapes(&self) -> bool {
1087        self.overlay_shape_gpu_data
1088            .as_ref()
1089            .map_or(false, |sd| sd.blur_vertex_count > 0)
1090    }
1091
1092    /// Draw blur overlay shapes into the given render pass using the texture pipeline.
1093    fn draw_blur_shapes<'rp>(
1094        &'rp self,
1095        render_pass: &mut wgpu::RenderPass<'rp>,
1096        blur_bind_group: &'rp wgpu::BindGroup,
1097    ) {
1098        if let Some(ref sd) = self.overlay_shape_gpu_data {
1099            if sd.blur_vertex_count > 0 {
1100                if let (Some(pipeline), Some(vbuf)) = (
1101                    &self.resources.overlay_shape_tex_pipeline,
1102                    &sd.blur_vertex_buf,
1103                ) {
1104                    render_pass.set_pipeline(pipeline);
1105                    render_pass.set_bind_group(0, blur_bind_group, &[]);
1106                    render_pass.set_vertex_buffer(0, vbuf.slice(..));
1107                    render_pass.draw(0..sd.blur_vertex_count, 0..1);
1108                }
1109            }
1110        }
1111    }
1112}