Skip to main content

viewport_lib/renderer/
render.rs

1use super::*;
2
3impl ViewportRenderer {
4    /// Issue draw calls for the viewport. Call inside a `wgpu::RenderPass`.
5    ///
6    /// This method requires a `'static` render pass (as provided by egui's
7    /// `CallbackTrait`). For non-static render passes (iced, manual wgpu),
8    /// use [`paint_to`](Self::paint_to).
9    pub fn paint(&self, render_pass: &mut wgpu::RenderPass<'static>, frame: &FrameData) {
10        let vp_idx = frame.camera.viewport_index;
11        let camera_bg = self.viewport_camera_bind_group(vp_idx);
12        let grid_bg = self.viewport_grid_bind_group(vp_idx);
13        let vp_slot = self.viewport_slots.get(vp_idx);
14        emit_draw_calls!(
15            &self.resources,
16            &mut *render_pass,
17            frame,
18            self.use_instancing,
19            &self.instanced_batches,
20            camera_bg,
21            grid_bg,
22            &self.compute_filter_results,
23            vp_slot
24        );
25        emit_scivis_draw_calls!(
26            &self.resources,
27            &mut *render_pass,
28            &self.point_cloud_gpu_data,
29            &self.glyph_gpu_data,
30            &self.polyline_gpu_data,
31            &self.volume_gpu_data,
32            &self.streamtube_gpu_data,
33            camera_bg
34        );
35        // Phase 16 : GPU implicit surface (depth-writes enabled, LessEqual compare).
36        if !self.implicit_gpu_data.is_empty() {
37            if let Some(pipeline) = &self.resources.implicit_pipeline {
38                render_pass.set_pipeline(pipeline);
39                render_pass.set_bind_group(0, camera_bg, &[]);
40                for gpu in &self.implicit_gpu_data {
41                    render_pass.set_bind_group(1, &gpu.bind_group, &[]);
42                    render_pass.draw(0..6, 0..1);
43                }
44            }
45        }
46        // Phase 17 : GPU marching cubes indirect draw.
47        if !self.mc_gpu_data.is_empty() {
48            if let Some(pipeline) = &self.resources.mc_surface_pipeline {
49                render_pass.set_pipeline(pipeline);
50                render_pass.set_bind_group(0, camera_bg, &[]);
51                for mc in &self.mc_gpu_data {
52                    let vol = &self.resources.mc_volumes[mc.volume_idx];
53                    render_pass.set_bind_group(1, &mc.render_bg, &[]);
54                    for slab in &vol.slabs {
55                        render_pass.set_vertex_buffer(0, slab.vertex_buf.slice(..));
56                        render_pass.draw_indirect(&slab.indirect_buf, 0);
57                    }
58                }
59            }
60        }
61        // Sub-object highlight (LDR path) : face fill, edge lines, vertex/point sprites.
62        if let Some(sub_hl) = self.viewport_slots.get(vp_idx).and_then(|s| s.sub_highlight.as_ref()) {
63            if let (Some(fill_pl), Some(edge_pl), Some(sprite_pl)) = (
64                &self.resources.sub_highlight_fill_ldr_pipeline,
65                &self.resources.sub_highlight_edge_ldr_pipeline,
66                &self.resources.sub_highlight_sprite_ldr_pipeline,
67            ) {
68                if sub_hl.fill_vertex_count > 0 {
69                    render_pass.set_pipeline(fill_pl);
70                    render_pass.set_bind_group(0, camera_bg, &[]);
71                    render_pass.set_bind_group(1, &sub_hl.fill_bind_group, &[]);
72                    render_pass.set_vertex_buffer(0, sub_hl.fill_vertex_buf.slice(..));
73                    render_pass.draw(0..sub_hl.fill_vertex_count, 0..1);
74                }
75                if sub_hl.edge_segment_count > 0 {
76                    render_pass.set_pipeline(edge_pl);
77                    render_pass.set_bind_group(0, camera_bg, &[]);
78                    render_pass.set_bind_group(1, &sub_hl.edge_bind_group, &[]);
79                    render_pass.set_vertex_buffer(0, sub_hl.edge_vertex_buf.slice(..));
80                    render_pass.draw(0..6, 0..sub_hl.edge_segment_count);
81                }
82                if sub_hl.sprite_point_count > 0 {
83                    render_pass.set_pipeline(sprite_pl);
84                    render_pass.set_bind_group(0, camera_bg, &[]);
85                    render_pass.set_bind_group(1, &sub_hl.sprite_bind_group, &[]);
86                    render_pass.set_vertex_buffer(0, sub_hl.sprite_vertex_buf.slice(..));
87                    render_pass.draw(0..6, 0..sub_hl.sprite_point_count);
88                }
89            }
90        }
91        // Phase 10B : screen-space image overlays (always on top, no depth test).
92        if !self.screen_image_gpu_data.is_empty() {
93            if let Some(pipeline) = &self.resources.screen_image_pipeline {
94                render_pass.set_pipeline(pipeline);
95                for gpu in &self.screen_image_gpu_data {
96                    render_pass.set_bind_group(0, &gpu.bind_group, &[]);
97                    render_pass.draw(0..6, 0..1);
98                }
99            }
100        }
101        // Overlay labels (always on top, after screen images).
102        if let Some(ref ld) = self.label_gpu_data {
103            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
104                render_pass.set_pipeline(pipeline);
105                render_pass.set_bind_group(0, &ld.bind_group, &[]);
106                render_pass.set_vertex_buffer(0, ld.vertex_buf.slice(..));
107                render_pass.draw(0..ld.vertex_count, 0..1);
108            }
109        }
110        // Scalar bars (drawn after labels).
111        if let Some(ref sb) = self.scalar_bar_gpu_data {
112            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
113                render_pass.set_pipeline(pipeline);
114                render_pass.set_bind_group(0, &sb.bind_group, &[]);
115                render_pass.set_vertex_buffer(0, sb.vertex_buf.slice(..));
116                render_pass.draw(0..sb.vertex_count, 0..1);
117            }
118        }
119        // Rulers (drawn after scalar bars).
120        if let Some(ref rd) = self.ruler_gpu_data {
121            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
122                render_pass.set_pipeline(pipeline);
123                render_pass.set_bind_group(0, &rd.bind_group, &[]);
124                render_pass.set_vertex_buffer(0, rd.vertex_buf.slice(..));
125                render_pass.draw(0..rd.vertex_count, 0..1);
126            }
127        }
128        // Phase 7 : overlay images (OverlayFrame, drawn last, no depth test).
129        if !self.overlay_image_gpu_data.is_empty() {
130            if let Some(pipeline) = &self.resources.screen_image_pipeline {
131                render_pass.set_pipeline(pipeline);
132                for gpu in &self.overlay_image_gpu_data {
133                    render_pass.set_bind_group(0, &gpu.bind_group, &[]);
134                    render_pass.draw(0..6, 0..1);
135                }
136            }
137        }
138    }
139
140    /// Issue draw calls into a render pass with any lifetime.
141    ///
142    /// Identical to [`paint`](Self::paint) but accepts a render pass with a
143    /// non-`'static` lifetime, making it usable from iced, raw wgpu, or any
144    /// framework that creates its own render pass.
145    pub fn paint_to<'rp>(&'rp self, render_pass: &mut wgpu::RenderPass<'rp>, frame: &FrameData) {
146        let vp_idx = frame.camera.viewport_index;
147        let camera_bg = self.viewport_camera_bind_group(vp_idx);
148        let grid_bg = self.viewport_grid_bind_group(vp_idx);
149        let vp_slot = self.viewport_slots.get(vp_idx);
150        emit_draw_calls!(
151            &self.resources,
152            &mut *render_pass,
153            frame,
154            self.use_instancing,
155            &self.instanced_batches,
156            camera_bg,
157            grid_bg,
158            &self.compute_filter_results,
159            vp_slot
160        );
161        emit_scivis_draw_calls!(
162            &self.resources,
163            &mut *render_pass,
164            &self.point_cloud_gpu_data,
165            &self.glyph_gpu_data,
166            &self.polyline_gpu_data,
167            &self.volume_gpu_data,
168            &self.streamtube_gpu_data,
169            camera_bg
170        );
171        // Phase 16 : GPU implicit surface (depth-writes enabled, LessEqual compare).
172        if !self.implicit_gpu_data.is_empty() {
173            if let Some(pipeline) = &self.resources.implicit_pipeline {
174                render_pass.set_pipeline(pipeline);
175                render_pass.set_bind_group(0, camera_bg, &[]);
176                for gpu in &self.implicit_gpu_data {
177                    render_pass.set_bind_group(1, &gpu.bind_group, &[]);
178                    render_pass.draw(0..6, 0..1);
179                }
180            }
181        }
182        // Phase 17 : GPU marching cubes indirect draw.
183        if !self.mc_gpu_data.is_empty() {
184            if let Some(pipeline) = &self.resources.mc_surface_pipeline {
185                render_pass.set_pipeline(pipeline);
186                render_pass.set_bind_group(0, camera_bg, &[]);
187                for mc in &self.mc_gpu_data {
188                    let vol = &self.resources.mc_volumes[mc.volume_idx];
189                    render_pass.set_bind_group(1, &mc.render_bg, &[]);
190                    for slab in &vol.slabs {
191                        render_pass.set_vertex_buffer(0, slab.vertex_buf.slice(..));
192                        render_pass.draw_indirect(&slab.indirect_buf, 0);
193                    }
194                }
195            }
196        }
197        // Sub-object highlight (LDR path) : face fill, edge lines, vertex/point sprites.
198        if let Some(sub_hl) = self.viewport_slots.get(vp_idx).and_then(|s| s.sub_highlight.as_ref()) {
199            if let (Some(fill_pl), Some(edge_pl), Some(sprite_pl)) = (
200                &self.resources.sub_highlight_fill_ldr_pipeline,
201                &self.resources.sub_highlight_edge_ldr_pipeline,
202                &self.resources.sub_highlight_sprite_ldr_pipeline,
203            ) {
204                if sub_hl.fill_vertex_count > 0 {
205                    render_pass.set_pipeline(fill_pl);
206                    render_pass.set_bind_group(0, camera_bg, &[]);
207                    render_pass.set_bind_group(1, &sub_hl.fill_bind_group, &[]);
208                    render_pass.set_vertex_buffer(0, sub_hl.fill_vertex_buf.slice(..));
209                    render_pass.draw(0..sub_hl.fill_vertex_count, 0..1);
210                }
211                if sub_hl.edge_segment_count > 0 {
212                    render_pass.set_pipeline(edge_pl);
213                    render_pass.set_bind_group(0, camera_bg, &[]);
214                    render_pass.set_bind_group(1, &sub_hl.edge_bind_group, &[]);
215                    render_pass.set_vertex_buffer(0, sub_hl.edge_vertex_buf.slice(..));
216                    render_pass.draw(0..6, 0..sub_hl.edge_segment_count);
217                }
218                if sub_hl.sprite_point_count > 0 {
219                    render_pass.set_pipeline(sprite_pl);
220                    render_pass.set_bind_group(0, camera_bg, &[]);
221                    render_pass.set_bind_group(1, &sub_hl.sprite_bind_group, &[]);
222                    render_pass.set_vertex_buffer(0, sub_hl.sprite_vertex_buf.slice(..));
223                    render_pass.draw(0..6, 0..sub_hl.sprite_point_count);
224                }
225            }
226        }
227        // Phase 10B : screen-space image overlays (always on top, no depth test).
228        if !self.screen_image_gpu_data.is_empty() {
229            if let Some(pipeline) = &self.resources.screen_image_pipeline {
230                render_pass.set_pipeline(pipeline);
231                for gpu in &self.screen_image_gpu_data {
232                    render_pass.set_bind_group(0, &gpu.bind_group, &[]);
233                    render_pass.draw(0..6, 0..1);
234                }
235            }
236        }
237        // Overlay labels (always on top, after screen images).
238        if let Some(ref ld) = self.label_gpu_data {
239            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
240                render_pass.set_pipeline(pipeline);
241                render_pass.set_bind_group(0, &ld.bind_group, &[]);
242                render_pass.set_vertex_buffer(0, ld.vertex_buf.slice(..));
243                render_pass.draw(0..ld.vertex_count, 0..1);
244            }
245        }
246        // Scalar bars (drawn after labels).
247        if let Some(ref sb) = self.scalar_bar_gpu_data {
248            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
249                render_pass.set_pipeline(pipeline);
250                render_pass.set_bind_group(0, &sb.bind_group, &[]);
251                render_pass.set_vertex_buffer(0, sb.vertex_buf.slice(..));
252                render_pass.draw(0..sb.vertex_count, 0..1);
253            }
254        }
255        // Rulers (drawn after scalar bars).
256        if let Some(ref rd) = self.ruler_gpu_data {
257            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
258                render_pass.set_pipeline(pipeline);
259                render_pass.set_bind_group(0, &rd.bind_group, &[]);
260                render_pass.set_vertex_buffer(0, rd.vertex_buf.slice(..));
261                render_pass.draw(0..rd.vertex_count, 0..1);
262            }
263        }
264        // Phase 7 : overlay images (OverlayFrame, drawn last, no depth test).
265        if !self.overlay_image_gpu_data.is_empty() {
266            if let Some(pipeline) = &self.resources.screen_image_pipeline {
267                render_pass.set_pipeline(pipeline);
268                for gpu in &self.overlay_image_gpu_data {
269                    render_pass.set_bind_group(0, &gpu.bind_group, &[]);
270                    render_pass.draw(0..6, 0..1);
271                }
272            }
273        }
274    }
275
276    /// High-level HDR render for a single viewport identified by `id`.
277    ///
278    /// Unlike [`render`](Self::render), this method does **not** call
279    /// [`prepare`](Self::prepare) internally.  The caller must have already called
280    /// [`prepare_scene`](Self::prepare_scene) and
281    /// [`prepare_viewport`](Self::prepare_viewport) for `id` before invoking this.
282    ///
283    /// This is the right entry point for multi-viewport frames:
284    /// 1. Call `prepare_scene` once.
285    /// 2. Call `prepare_viewport` for each viewport.
286    /// 3. Call `render_viewport` for each viewport with its own `output_view`.
287    ///
288    /// Returns a [`wgpu::CommandBuffer`] ready to submit.
289    pub fn render_viewport(
290        &mut self,
291        device: &wgpu::Device,
292        queue: &wgpu::Queue,
293        output_view: &wgpu::TextureView,
294        id: ViewportId,
295        frame: &FrameData,
296    ) -> wgpu::CommandBuffer {
297        self.render_frame_internal(device, queue, output_view, id.0, frame)
298    }
299
300    /// High-level HDR render method. Handles the full post-processing pipeline:
301    /// scene -> HDR texture -> (bloom) -> (SSAO) -> tone map -> output_view.
302    ///
303    /// When `frame.post_process.enabled` is false, falls back to a simple LDR render
304    /// pass targeting `output_view` directly.
305    ///
306    /// Returns a `CommandBuffer` ready to submit.
307    pub fn render(
308        &mut self,
309        device: &wgpu::Device,
310        queue: &wgpu::Queue,
311        output_view: &wgpu::TextureView,
312        frame: &FrameData,
313    ) -> wgpu::CommandBuffer {
314        // Always run prepare() to upload uniforms and run the shadow pass.
315        self.prepare(device, queue, frame);
316        self.render_frame_internal(
317            device,
318            queue,
319            output_view,
320            frame.camera.viewport_index,
321            frame,
322        )
323    }
324
325    /// Render-only path shared by `render()` and `render_viewport()`.
326    ///
327    /// `vp_idx` selects the per-viewport slot to use for camera/HDR state,
328    /// independent of `frame.camera.viewport_index`.
329    fn render_frame_internal(
330        &mut self,
331        device: &wgpu::Device,
332        queue: &wgpu::Queue,
333        output_view: &wgpu::TextureView,
334        vp_idx: usize,
335        frame: &FrameData,
336    ) -> wgpu::CommandBuffer {
337        // Resolve scene items from the SurfaceSubmission seam.
338        let scene_items: &[SceneRenderItem] = match &frame.scene.surfaces {
339            SurfaceSubmission::Flat(items) => items,
340        };
341
342        let bg_color = frame.viewport.background_color.unwrap_or([
343            65.0 / 255.0,
344            65.0 / 255.0,
345            65.0 / 255.0,
346            1.0,
347        ]);
348        let w = frame.camera.viewport_size[0] as u32;
349        let h = frame.camera.viewport_size[1] as u32;
350
351        // Ensure per-viewport HDR targets. Provides a depth buffer for both LDR and HDR paths.
352        let ssaa_factor = frame.effects.post_process.ssaa_factor.max(1);
353        self.ensure_viewport_hdr(device, queue, vp_idx, w.max(1), h.max(1), ssaa_factor);
354
355        // Phase 4 : lazy-initialize GPU timestamp resources on first render call when supported.
356        if self.ts_query_set.is_none()
357            && device.features().contains(wgpu::Features::TIMESTAMP_QUERY)
358        {
359            self.ts_query_set = Some(device.create_query_set(&wgpu::QuerySetDescriptor {
360                label: Some("ts_query_set"),
361                ty: wgpu::QueryType::Timestamp,
362                count: 2,
363            }));
364            self.ts_resolve_buf = Some(device.create_buffer(&wgpu::BufferDescriptor {
365                label: Some("ts_resolve_buf"),
366                size: 16,
367                usage: wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC,
368                mapped_at_creation: false,
369            }));
370            self.ts_staging_buf = Some(device.create_buffer(&wgpu::BufferDescriptor {
371                label: Some("ts_staging_buf"),
372                size: 16,
373                usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
374                mapped_at_creation: false,
375            }));
376            self.ts_period = queue.get_timestamp_period();
377        }
378
379        if !frame.effects.post_process.enabled {
380            // LDR fallback. When dynamic resolution is active and render_scale < 1.0,
381            // draw into a scaled intermediate texture and upscale-blit to output_view.
382            // Otherwise render directly to output_view.
383            let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
384                label: Some("ldr_encoder"),
385            });
386
387            let use_dyn_res = self.performance_policy.allow_dynamic_resolution
388                && self.current_render_scale < 1.0;
389
390            if use_dyn_res {
391                let sw = ((w as f32 * self.current_render_scale) as u32).max(1);
392                let sh = ((h as f32 * self.current_render_scale) as u32).max(1);
393                self.ensure_dyn_res_target(device, vp_idx, [sw, sh], [w.max(1), h.max(1)]);
394            }
395
396            {
397                let slot = &self.viewport_slots[vp_idx];
398                let slot_hdr = slot.hdr.as_ref().unwrap();
399                let camera_bg = &slot.camera_bind_group;
400                let grid_bg = &slot.grid_bind_group;
401                // Choose render target: dyn_res intermediate or directly output_view.
402                let (scene_color_view, scene_depth_view): (&wgpu::TextureView, &wgpu::TextureView) =
403                    if use_dyn_res {
404                        let dr = slot.dyn_res.as_ref().unwrap();
405                        (&dr.color_view, &dr.depth_view)
406                    } else {
407                        (output_view, &slot_hdr.outline_depth_view)
408                    };
409                let ts_writes = self.ts_query_set.as_ref().map(|qs| {
410                    wgpu::RenderPassTimestampWrites {
411                        query_set: qs,
412                        beginning_of_pass_write_index: Some(0),
413                        end_of_pass_write_index: Some(1),
414                    }
415                });
416                let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
417                    label: Some("ldr_render_pass"),
418                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
419                        view: scene_color_view,
420                        resolve_target: None,
421                        ops: wgpu::Operations {
422                            load: wgpu::LoadOp::Clear(wgpu::Color {
423                                r: bg_color[0] as f64,
424                                g: bg_color[1] as f64,
425                                b: bg_color[2] as f64,
426                                a: bg_color[3] as f64,
427                            }),
428                            store: wgpu::StoreOp::Store,
429                        },
430                        depth_slice: None,
431                    })],
432                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
433                        view: scene_depth_view,
434                        depth_ops: Some(wgpu::Operations {
435                            load: wgpu::LoadOp::Clear(1.0),
436                            store: wgpu::StoreOp::Discard,
437                        }),
438                        stencil_ops: None,
439                    }),
440                    timestamp_writes: ts_writes,
441                    occlusion_query_set: None,
442                });
443                emit_draw_calls!(
444                    &self.resources,
445                    &mut render_pass,
446                    frame,
447                    self.use_instancing,
448                    &self.instanced_batches,
449                    camera_bg,
450                    grid_bg,
451                    &self.compute_filter_results,
452                    Some(slot)
453                );
454                emit_scivis_draw_calls!(
455                    &self.resources,
456                    &mut render_pass,
457                    &self.point_cloud_gpu_data,
458                    &self.glyph_gpu_data,
459                    &self.polyline_gpu_data,
460                    &self.volume_gpu_data,
461                    &self.streamtube_gpu_data,
462                    camera_bg
463                );
464                // Phase 16 : GPU implicit surface.
465                if !self.implicit_gpu_data.is_empty() {
466                    if let Some(pipeline) = &self.resources.implicit_pipeline {
467                        render_pass.set_pipeline(pipeline);
468                        render_pass.set_bind_group(0, camera_bg, &[]);
469                        for gpu in &self.implicit_gpu_data {
470                            render_pass.set_bind_group(1, &gpu.bind_group, &[]);
471                            render_pass.draw(0..6, 0..1);
472                        }
473                    }
474                }
475                // Phase 17 : GPU marching cubes indirect draw.
476                if !self.mc_gpu_data.is_empty() {
477                    if let Some(pipeline) = &self.resources.mc_surface_pipeline {
478                        render_pass.set_pipeline(pipeline);
479                        render_pass.set_bind_group(0, camera_bg, &[]);
480                        for mc in &self.mc_gpu_data {
481                            let vol = &self.resources.mc_volumes[mc.volume_idx];
482                            render_pass.set_bind_group(1, &mc.render_bg, &[]);
483                            for slab in &vol.slabs {
484                                render_pass.set_vertex_buffer(0, slab.vertex_buf.slice(..));
485                                render_pass.draw_indirect(&slab.indirect_buf, 0);
486                            }
487                        }
488                    }
489                }
490                // Phase 10B / Phase 12 : screen-space image overlays.
491                // Regular items drawn with depth_compare: Always (always on top).
492                // Depth-composite items drawn with depth_compare: LessEqual (occluded by
493                // scene geometry whose depth was already written to the depth attachment).
494                if !self.screen_image_gpu_data.is_empty() {
495                    if let Some(overlay_pipeline) = &self.resources.screen_image_pipeline {
496                        let dc_pipeline = self.resources.screen_image_dc_pipeline.as_ref();
497                        for gpu in &self.screen_image_gpu_data {
498                            if let (Some(dc_bg), Some(dc_pipe)) =
499                                (&gpu.depth_bind_group, dc_pipeline)
500                            {
501                                render_pass.set_pipeline(dc_pipe);
502                                render_pass.set_bind_group(0, dc_bg, &[]);
503                            } else {
504                                render_pass.set_pipeline(overlay_pipeline);
505                                render_pass.set_bind_group(0, &gpu.bind_group, &[]);
506                            }
507                            render_pass.draw(0..6, 0..1);
508                        }
509                    }
510                }
511                // Overlay labels (LDR fallback: inside the same render pass).
512                if let Some(ref ld) = self.label_gpu_data {
513                    if let Some(pipeline) = &self.resources.overlay_text_pipeline {
514                        render_pass.set_pipeline(pipeline);
515                        render_pass.set_bind_group(0, &ld.bind_group, &[]);
516                        render_pass.set_vertex_buffer(0, ld.vertex_buf.slice(..));
517                        render_pass.draw(0..ld.vertex_count, 0..1);
518                    }
519                }
520                // Scalar bars (LDR fallback).
521                if let Some(ref sb) = self.scalar_bar_gpu_data {
522                    if let Some(pipeline) = &self.resources.overlay_text_pipeline {
523                        render_pass.set_pipeline(pipeline);
524                        render_pass.set_bind_group(0, &sb.bind_group, &[]);
525                        render_pass.set_vertex_buffer(0, sb.vertex_buf.slice(..));
526                        render_pass.draw(0..sb.vertex_count, 0..1);
527                    }
528                }
529                // Rulers (LDR fallback).
530                if let Some(ref rd) = self.ruler_gpu_data {
531                    if let Some(pipeline) = &self.resources.overlay_text_pipeline {
532                        render_pass.set_pipeline(pipeline);
533                        render_pass.set_bind_group(0, &rd.bind_group, &[]);
534                        render_pass.set_vertex_buffer(0, rd.vertex_buf.slice(..));
535                        render_pass.draw(0..rd.vertex_count, 0..1);
536                    }
537                }
538                // Phase 7 : overlay images (OverlayFrame, LDR fallback, drawn last).
539                if !self.overlay_image_gpu_data.is_empty() {
540                    if let Some(pipeline) = &self.resources.screen_image_pipeline {
541                        render_pass.set_pipeline(pipeline);
542                        for gpu in &self.overlay_image_gpu_data {
543                            render_pass.set_bind_group(0, &gpu.bind_group, &[]);
544                            render_pass.draw(0..6, 0..1);
545                        }
546                    }
547                }
548            }
549
550            // Phase 4 : resolve timestamp queries -> staging buffer.
551            if let (Some(qs), Some(res_buf), Some(stg_buf)) = (
552                self.ts_query_set.as_ref(),
553                self.ts_resolve_buf.as_ref(),
554                self.ts_staging_buf.as_ref(),
555            ) {
556                encoder.resolve_query_set(qs, 0..2, res_buf, 0);
557                encoder.copy_buffer_to_buffer(res_buf, 0, stg_buf, 0, 16);
558                self.ts_needs_readback = true;
559            }
560
561            // Phase 3 : upscale blit from dyn_res intermediate to output_view.
562            if use_dyn_res {
563                let upscale_bg =
564                    &self.viewport_slots[vp_idx].dyn_res.as_ref().unwrap().upscale_bind_group;
565                let mut upscale_pass =
566                    encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
567                        label: Some("dyn_res_upscale_pass"),
568                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
569                            view: output_view,
570                            resolve_target: None,
571                            ops: wgpu::Operations {
572                                load: wgpu::LoadOp::Load,
573                                store: wgpu::StoreOp::Store,
574                            },
575                            depth_slice: None,
576                        })],
577                        depth_stencil_attachment: None,
578                        timestamp_writes: None,
579                        occlusion_query_set: None,
580                    });
581                if let Some(pipeline) = &self.resources.dyn_res_upscale_pipeline {
582                    upscale_pass.set_pipeline(pipeline);
583                    upscale_pass.set_bind_group(0, upscale_bg, &[]);
584                    upscale_pass.draw(0..3, 0..1);
585                }
586            }
587
588            return encoder.finish();
589        }
590
591        // HDR path.
592        let pp = &frame.effects.post_process;
593
594        let hdr_clear_rgb = [
595            bg_color[0].powf(2.2),
596            bg_color[1].powf(2.2),
597            bg_color[2].powf(2.2),
598        ];
599
600        // Upload tone map uniform into the per-viewport buffer.
601        let mode = match pp.tone_mapping {
602            crate::renderer::ToneMapping::Reinhard => 0u32,
603            crate::renderer::ToneMapping::Aces => 1u32,
604            crate::renderer::ToneMapping::KhronosNeutral => 2u32,
605        };
606        let tm_uniform = crate::resources::ToneMapUniform {
607            exposure: pp.exposure,
608            mode,
609            bloom_enabled: if pp.bloom { 1 } else { 0 },
610            ssao_enabled: if pp.ssao { 1 } else { 0 },
611            contact_shadows_enabled: if pp.contact_shadows { 1 } else { 0 },
612            _pad_tm: [0; 3],
613            background_color: bg_color,
614        };
615        {
616            let hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
617            queue.write_buffer(
618                &hdr.tone_map_uniform_buf,
619                0,
620                bytemuck::cast_slice(&[tm_uniform]),
621            );
622
623            // Upload SSAO uniform if needed.
624            if pp.ssao {
625                let proj = frame.camera.render_camera.projection;
626                let inv_proj = proj.inverse();
627                let ssao_uniform = crate::resources::SsaoUniform {
628                    inv_proj: inv_proj.to_cols_array_2d(),
629                    proj: proj.to_cols_array_2d(),
630                    radius: 0.5,
631                    bias: 0.025,
632                    _pad: [0.0; 2],
633                };
634                queue.write_buffer(
635                    &hdr.ssao_uniform_buf,
636                    0,
637                    bytemuck::cast_slice(&[ssao_uniform]),
638                );
639            }
640
641            // Upload contact shadow uniform if needed.
642            if pp.contact_shadows {
643                let proj = frame.camera.render_camera.projection;
644                let inv_proj = proj.inverse();
645                let light_dir_world: glam::Vec3 =
646                    if let Some(l) = frame.effects.lighting.lights.first() {
647                        match l.kind {
648                            LightKind::Directional { direction } => {
649                                glam::Vec3::from(direction).normalize()
650                            }
651                            LightKind::Spot { direction, .. } => {
652                                glam::Vec3::from(direction).normalize()
653                            }
654                            _ => glam::Vec3::new(0.0, -1.0, 0.0),
655                        }
656                    } else {
657                        glam::Vec3::new(0.0, -1.0, 0.0)
658                    };
659                let view = frame.camera.render_camera.view;
660                let light_dir_view = view.transform_vector3(light_dir_world).normalize();
661                let world_up_view = view.transform_vector3(glam::Vec3::Z).normalize();
662                let cs_uniform = crate::resources::ContactShadowUniform {
663                    inv_proj: inv_proj.to_cols_array_2d(),
664                    proj: proj.to_cols_array_2d(),
665                    light_dir_view: [light_dir_view.x, light_dir_view.y, light_dir_view.z, 0.0],
666                    world_up_view: [world_up_view.x, world_up_view.y, world_up_view.z, 0.0],
667                    params: [
668                        pp.contact_shadow_max_distance,
669                        pp.contact_shadow_steps as f32,
670                        pp.contact_shadow_thickness,
671                        0.0,
672                    ],
673                };
674                queue.write_buffer(
675                    &hdr.contact_shadow_uniform_buf,
676                    0,
677                    bytemuck::cast_slice(&[cs_uniform]),
678                );
679            }
680
681            // Upload bloom uniform if needed.
682            if pp.bloom {
683                let bloom_u = crate::resources::BloomUniform {
684                    threshold: pp.bloom_threshold,
685                    intensity: pp.bloom_intensity,
686                    horizontal: 0,
687                    _pad: 0,
688                };
689                queue.write_buffer(&hdr.bloom_uniform_buf, 0, bytemuck::cast_slice(&[bloom_u]));
690            }
691        }
692
693        // Rebuild tone-map bind group with correct bloom/AO texture views.
694        {
695            let hdr = self.viewport_slots[vp_idx].hdr.as_mut().unwrap();
696            self.resources.rebuild_tone_map_bind_group(
697                device,
698                hdr,
699                pp.bloom,
700                pp.ssao,
701                pp.contact_shadows,
702            );
703        }
704
705        // -----------------------------------------------------------------------
706        // Pre-allocate OIT targets if any transparent items exist.
707        // Must happen before camera_bg is borrowed (borrow-checker constraint).
708        // -----------------------------------------------------------------------
709        {
710            let needs_oit = if self.use_instancing && !self.instanced_batches.is_empty() {
711                self.instanced_batches.iter().any(|b| b.is_transparent)
712            } else {
713                scene_items
714                    .iter()
715                    .any(|i| i.visible && i.material.opacity < 1.0)
716            };
717            if needs_oit {
718                let hdr = self.viewport_slots[vp_idx].hdr.as_mut().unwrap();
719                self.resources
720                    .ensure_viewport_oit(device, hdr, w.max(1), h.max(1));
721            }
722        }
723
724        // -----------------------------------------------------------------------
725        // Build the command encoder.
726        // -----------------------------------------------------------------------
727        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
728            label: Some("hdr_encoder"),
729        });
730
731        // Per-viewport camera bind group and HDR state for the HDR path.
732        let slot = &self.viewport_slots[vp_idx];
733        let camera_bg = &slot.camera_bind_group;
734        let slot_hdr = slot.hdr.as_ref().unwrap();
735
736        // -----------------------------------------------------------------------
737        // HDR scene pass: render geometry into the HDR texture.
738        // -----------------------------------------------------------------------
739        {
740            // Use SSAA target if enabled, otherwise render directly to hdr_texture.
741            let use_ssaa = ssaa_factor > 1
742                && slot_hdr.ssaa_color_view.is_some()
743                && slot_hdr.ssaa_depth_view.is_some();
744            let scene_color_view = if use_ssaa {
745                slot_hdr.ssaa_color_view.as_ref().unwrap()
746            } else {
747                &slot_hdr.hdr_view
748            };
749            let scene_depth_view = if use_ssaa {
750                slot_hdr.ssaa_depth_view.as_ref().unwrap()
751            } else {
752                &slot_hdr.hdr_depth_view
753            };
754
755            let clear_wgpu = wgpu::Color {
756                r: hdr_clear_rgb[0] as f64,
757                g: hdr_clear_rgb[1] as f64,
758                b: hdr_clear_rgb[2] as f64,
759                a: bg_color[3] as f64,
760            };
761
762            let hdr_ts_writes = self.ts_query_set.as_ref().map(|qs| {
763                wgpu::RenderPassTimestampWrites {
764                    query_set: qs,
765                    beginning_of_pass_write_index: Some(0),
766                    end_of_pass_write_index: Some(1),
767                }
768            });
769            let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
770                label: Some("hdr_scene_pass"),
771                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
772                    view: scene_color_view,
773                    resolve_target: None,
774                    ops: wgpu::Operations {
775                        load: wgpu::LoadOp::Clear(clear_wgpu),
776                        store: wgpu::StoreOp::Store,
777                    },
778                    depth_slice: None,
779                })],
780                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
781                    view: scene_depth_view,
782                    depth_ops: Some(wgpu::Operations {
783                        load: wgpu::LoadOp::Clear(1.0),
784                        store: wgpu::StoreOp::Store,
785                    }),
786                    stencil_ops: Some(wgpu::Operations {
787                        load: wgpu::LoadOp::Clear(0),
788                        store: wgpu::StoreOp::Store,
789                    }),
790                }),
791                timestamp_writes: hdr_ts_writes,
792                occlusion_query_set: None,
793            });
794
795            let resources = &self.resources;
796            render_pass.set_bind_group(0, camera_bg, &[]);
797
798            // Check skybox eligibility early; drawn after all opaques below.
799            let show_skybox = frame
800                .effects
801                .environment
802                .as_ref()
803                .is_some_and(|e| e.show_skybox)
804                && resources.ibl_skybox_view.is_some();
805
806            let use_instancing = self.use_instancing;
807            let batches = &self.instanced_batches;
808
809            if !scene_items.is_empty() {
810                if use_instancing && !batches.is_empty() {
811                    let excluded_items: Vec<&SceneRenderItem> = scene_items
812                        .iter()
813                        .filter(|item| {
814                            item.visible
815                                && (item.active_attribute.is_some()
816                                    || item.material.is_two_sided()
817                                    || item.material.matcap_id.is_some())
818                                && resources
819                                    .mesh_store
820                                    .get(item.mesh_id)
821                                    .is_some()
822                        })
823                        .collect();
824
825                    // Separate opaque and transparent batches.
826                    let mut opaque_batches: Vec<&InstancedBatch> = Vec::new();
827                    let mut transparent_batches: Vec<&InstancedBatch> = Vec::new();
828                    for batch in batches {
829                        if batch.is_transparent {
830                            transparent_batches.push(batch);
831                        } else {
832                            opaque_batches.push(batch);
833                        }
834                    }
835
836                    if !opaque_batches.is_empty() && !frame.viewport.wireframe_mode {
837                        if let Some(ref pipeline) = resources.hdr_solid_instanced_pipeline {
838                            render_pass.set_pipeline(pipeline);
839                            for batch in &opaque_batches {
840                                let Some(mesh) = resources
841                                    .mesh_store
842                                    .get(batch.mesh_id)
843                                else {
844                                    continue;
845                                };
846                                let mat_key = (
847                                    batch.texture_id.unwrap_or(u64::MAX),
848                                    batch.normal_map_id.unwrap_or(u64::MAX),
849                                    batch.ao_map_id.unwrap_or(u64::MAX),
850                                );
851                                let Some(inst_tex_bg) =
852                                    resources.instance_bind_groups.get(&mat_key)
853                                else {
854                                    continue;
855                                };
856                                render_pass.set_bind_group(1, inst_tex_bg, &[]);
857                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
858                                render_pass.set_index_buffer(
859                                    mesh.index_buffer.slice(..),
860                                    wgpu::IndexFormat::Uint32,
861                                );
862                                render_pass.draw_indexed(
863                                    0..mesh.index_count,
864                                    0,
865                                    batch.instance_offset
866                                        ..batch.instance_offset + batch.instance_count,
867                                );
868                            }
869                        }
870                    }
871
872                    // NOTE: transparent_batches are now rendered in the OIT pass below,
873                    // not in the HDR scene pass. This block intentionally left empty.
874                    let _ = &transparent_batches; // suppress unused warning
875
876                    if frame.viewport.wireframe_mode {
877                        if let Some(ref hdr_wf) = resources.hdr_wireframe_pipeline {
878                            render_pass.set_pipeline(hdr_wf);
879                            for item in scene_items {
880                                if !item.visible {
881                                    continue;
882                                }
883                                let Some(mesh) = resources
884                                    .mesh_store
885                                    .get(item.mesh_id)
886                                else {
887                                    continue;
888                                };
889                                render_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
890                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
891                                render_pass.set_index_buffer(
892                                    mesh.edge_index_buffer.slice(..),
893                                    wgpu::IndexFormat::Uint32,
894                                );
895                                render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
896                            }
897                        }
898                    } else if let (Some(hdr_solid), Some(hdr_solid_two_sided)) = (
899                        &resources.hdr_solid_pipeline,
900                        &resources.hdr_solid_two_sided_pipeline,
901                    ) {
902                        for item in excluded_items
903                            .into_iter()
904                            .filter(|item| item.material.opacity >= 1.0)
905                        {
906                            let Some(mesh) = resources
907                                .mesh_store
908                                .get(item.mesh_id)
909                            else {
910                                continue;
911                            };
912                            let pipeline = if item.material.is_two_sided() {
913                                hdr_solid_two_sided
914                            } else {
915                                hdr_solid
916                            };
917                            render_pass.set_pipeline(pipeline);
918                            render_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
919                            render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
920                            render_pass.set_index_buffer(
921                                mesh.index_buffer.slice(..),
922                                wgpu::IndexFormat::Uint32,
923                            );
924                            render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
925                        }
926                    }
927                } else {
928                    // Per-object path.
929                    let eye = glam::Vec3::from(frame.camera.render_camera.eye_position);
930                    let dist_from_eye = |item: &&SceneRenderItem| -> f32 {
931                        let pos =
932                            glam::Vec3::new(item.model[3][0], item.model[3][1], item.model[3][2]);
933                        (pos - eye).length()
934                    };
935
936                    let mut opaque: Vec<&SceneRenderItem> = Vec::new();
937                    let mut transparent: Vec<&SceneRenderItem> = Vec::new();
938                    for item in scene_items {
939                        if !item.visible
940                            || resources
941                                .mesh_store
942                                .get(item.mesh_id)
943                                .is_none()
944                        {
945                            continue;
946                        }
947                        if item.material.opacity < 1.0 {
948                            transparent.push(item);
949                        } else {
950                            opaque.push(item);
951                        }
952                    }
953                    opaque.sort_by(|a, b| {
954                        dist_from_eye(a)
955                            .partial_cmp(&dist_from_eye(b))
956                            .unwrap_or(std::cmp::Ordering::Equal)
957                    });
958                    transparent.sort_by(|a, b| {
959                        dist_from_eye(b)
960                            .partial_cmp(&dist_from_eye(a))
961                            .unwrap_or(std::cmp::Ordering::Equal)
962                    });
963
964                    let draw_item_hdr =
965                        |render_pass: &mut wgpu::RenderPass<'_>,
966                         item: &SceneRenderItem,
967                         solid_pl: &wgpu::RenderPipeline,
968                         trans_pl: &wgpu::RenderPipeline,
969                         wf_pl: &wgpu::RenderPipeline| {
970                            let mesh = resources
971                                .mesh_store
972                                .get(item.mesh_id)
973                                .unwrap();
974                            // mesh.object_bind_group (group 1) already carries the object uniform
975                            // and the correct texture views.
976                            render_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
977                            let is_face_attr = item.active_attribute.as_ref().map_or(false, |a| {
978                                matches!(
979                                    a.kind,
980                                    crate::resources::AttributeKind::Face
981                                        | crate::resources::AttributeKind::FaceColor
982                                        | crate::resources::AttributeKind::Halfedge
983                                        | crate::resources::AttributeKind::Corner
984                                )
985                            });
986                            if frame.viewport.wireframe_mode {
987                                render_pass.set_pipeline(wf_pl);
988                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
989                                render_pass.set_index_buffer(
990                                    mesh.edge_index_buffer.slice(..),
991                                    wgpu::IndexFormat::Uint32,
992                                );
993                                render_pass.draw_indexed(0..mesh.edge_index_count, 0, 0..1);
994                            } else if is_face_attr {
995                                if let Some(ref fvb) = mesh.face_vertex_buffer {
996                                    let pl = if item.material.opacity < 1.0 {
997                                        trans_pl
998                                    } else {
999                                        solid_pl
1000                                    };
1001                                    render_pass.set_pipeline(pl);
1002                                    render_pass.set_vertex_buffer(0, fvb.slice(..));
1003                                    render_pass.draw(0..mesh.index_count, 0..1);
1004                                }
1005                            } else if item.material.opacity < 1.0 {
1006                                render_pass.set_pipeline(trans_pl);
1007                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1008                                render_pass.set_index_buffer(
1009                                    mesh.index_buffer.slice(..),
1010                                    wgpu::IndexFormat::Uint32,
1011                                );
1012                                render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1013                            } else {
1014                                render_pass.set_pipeline(solid_pl);
1015                                render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1016                                render_pass.set_index_buffer(
1017                                    mesh.index_buffer.slice(..),
1018                                    wgpu::IndexFormat::Uint32,
1019                                );
1020                                render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1021                            }
1022                        };
1023
1024                    // NOTE: only opaque items are drawn here. Transparent items are
1025                    // routed to the OIT pass below.
1026                    let _ = &transparent; // suppress unused warning
1027                    if let (
1028                        Some(hdr_solid),
1029                        Some(hdr_solid_two_sided),
1030                        Some(hdr_trans),
1031                        Some(hdr_wf),
1032                    ) = (
1033                        &resources.hdr_solid_pipeline,
1034                        &resources.hdr_solid_two_sided_pipeline,
1035                        &resources.hdr_transparent_pipeline,
1036                        &resources.hdr_wireframe_pipeline,
1037                    ) {
1038                        for item in &opaque {
1039                            let solid_pl = if item.material.is_two_sided() {
1040                                hdr_solid_two_sided
1041                            } else {
1042                                hdr_solid
1043                            };
1044                            draw_item_hdr(&mut render_pass, item, solid_pl, hdr_trans, hdr_wf);
1045                        }
1046                    }
1047                }
1048            }
1049
1050            // Cap fill pass (HDR path : section view cross-section fill).
1051            if !slot.cap_buffers.is_empty() {
1052                if let Some(ref hdr_overlay) = resources.hdr_overlay_pipeline {
1053                    render_pass.set_pipeline(hdr_overlay);
1054                    render_pass.set_bind_group(0, camera_bg, &[]);
1055                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.cap_buffers {
1056                        render_pass.set_bind_group(1, bg, &[]);
1057                        render_pass.set_vertex_buffer(0, vbuf.slice(..));
1058                        render_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
1059                        render_pass.draw_indexed(0..*idx_count, 0, 0..1);
1060                    }
1061                }
1062            }
1063
1064            // SciVis Phase B+D+M8+M: point cloud, glyph, polyline, volume, streamtube (HDR path).
1065            emit_scivis_draw_calls!(
1066                &self.resources,
1067                &mut render_pass,
1068                &self.point_cloud_gpu_data,
1069                &self.glyph_gpu_data,
1070                &self.polyline_gpu_data,
1071                &self.volume_gpu_data,
1072                &self.streamtube_gpu_data,
1073                camera_bg
1074            );
1075
1076            // Phase 16 : GPU implicit surface (HDR path, before skybox).
1077            if !self.implicit_gpu_data.is_empty() {
1078                if let Some(pipeline) = &self.resources.implicit_pipeline {
1079                    render_pass.set_pipeline(pipeline);
1080                    render_pass.set_bind_group(0, camera_bg, &[]);
1081                    for gpu in &self.implicit_gpu_data {
1082                        render_pass.set_bind_group(1, &gpu.bind_group, &[]);
1083                        render_pass.draw(0..6, 0..1);
1084                    }
1085                }
1086            }
1087            // Phase 17 : GPU marching cubes indirect draw (HDR path).
1088            if !self.mc_gpu_data.is_empty() {
1089                if let Some(pipeline) = &self.resources.mc_surface_pipeline {
1090                    render_pass.set_pipeline(pipeline);
1091                    render_pass.set_bind_group(0, camera_bg, &[]);
1092                    for mc in &self.mc_gpu_data {
1093                        let vol = &self.resources.mc_volumes[mc.volume_idx];
1094                        render_pass.set_bind_group(1, &mc.render_bg, &[]);
1095                        for slab in &vol.slabs {
1096                            render_pass.set_vertex_buffer(0, slab.vertex_buf.slice(..));
1097                            render_pass.draw_indirect(&slab.indirect_buf, 0);
1098                        }
1099                    }
1100                }
1101            }
1102
1103            // Draw skybox last among opaques : only uncovered sky pixels pass depth == 1.0.
1104            if show_skybox {
1105                render_pass.set_bind_group(0, camera_bg, &[]);
1106                render_pass.set_pipeline(&resources.skybox_pipeline);
1107                render_pass.draw(0..3, 0..1);
1108            }
1109        }
1110
1111        // -----------------------------------------------------------------------
1112        // SSAA resolve pass: downsample supersampled scene -> hdr_texture.
1113        // Only runs when ssaa_factor > 1 and the resolve pipeline is available.
1114        // -----------------------------------------------------------------------
1115        if ssaa_factor > 1 {
1116            let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
1117            if let (Some(pipeline), Some(bg)) = (
1118                &self.resources.ssaa_resolve_pipeline,
1119                &slot_hdr.ssaa_resolve_bind_group,
1120            ) {
1121                let mut resolve_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1122                    label: Some("ssaa_resolve_pass"),
1123                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1124                        view: &slot_hdr.hdr_view,
1125                        resolve_target: None,
1126                        ops: wgpu::Operations {
1127                            load: wgpu::LoadOp::Load,
1128                            store: wgpu::StoreOp::Store,
1129                        },
1130                        depth_slice: None,
1131                    })],
1132                    depth_stencil_attachment: None,
1133                    timestamp_writes: None,
1134                    occlusion_query_set: None,
1135                });
1136                resolve_pass.set_pipeline(pipeline);
1137                resolve_pass.set_bind_group(0, bg, &[]);
1138                resolve_pass.draw(0..3, 0..1);
1139            }
1140        }
1141
1142        // -----------------------------------------------------------------------
1143        // Sub-object highlight pass: face fill, edge lines, vertex sprites.
1144        // Runs after opaque geometry (depth buffer is ready) and before OIT so
1145        // highlights are not occluded by opaque surfaces.
1146        // -----------------------------------------------------------------------
1147        if let Some(sub_hl) = self.viewport_slots[vp_idx].sub_highlight.as_ref() {
1148            let resources = &self.resources;
1149            if let (Some(fill_pl), Some(edge_pl), Some(sprite_pl)) = (
1150                &resources.sub_highlight_fill_pipeline,
1151                &resources.sub_highlight_edge_pipeline,
1152                &resources.sub_highlight_sprite_pipeline,
1153            ) {
1154                let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
1155                let camera_bg = &self.viewport_slots[vp_idx].camera_bind_group;
1156                let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1157                    label: Some("sub_highlight_pass"),
1158                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1159                        view: &slot_hdr.hdr_view,
1160                        resolve_target: None,
1161                        ops: wgpu::Operations {
1162                            load: wgpu::LoadOp::Load,
1163                            store: wgpu::StoreOp::Store,
1164                        },
1165                        depth_slice: None,
1166                    })],
1167                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1168                        view: &slot_hdr.hdr_depth_view,
1169                        depth_ops: Some(wgpu::Operations {
1170                            load: wgpu::LoadOp::Load,
1171                            store: wgpu::StoreOp::Discard,
1172                        }),
1173                        stencil_ops: None,
1174                    }),
1175                    timestamp_writes: None,
1176                    occlusion_query_set: None,
1177                });
1178
1179                if sub_hl.fill_vertex_count > 0 {
1180                    pass.set_pipeline(fill_pl);
1181                    pass.set_bind_group(0, camera_bg, &[]);
1182                    pass.set_bind_group(1, &sub_hl.fill_bind_group, &[]);
1183                    pass.set_vertex_buffer(0, sub_hl.fill_vertex_buf.slice(..));
1184                    pass.draw(0..sub_hl.fill_vertex_count, 0..1);
1185                }
1186                if sub_hl.edge_segment_count > 0 {
1187                    pass.set_pipeline(edge_pl);
1188                    pass.set_bind_group(0, camera_bg, &[]);
1189                    pass.set_bind_group(1, &sub_hl.edge_bind_group, &[]);
1190                    pass.set_vertex_buffer(0, sub_hl.edge_vertex_buf.slice(..));
1191                    pass.draw(0..6, 0..sub_hl.edge_segment_count);
1192                }
1193                if sub_hl.sprite_point_count > 0 {
1194                    pass.set_pipeline(sprite_pl);
1195                    pass.set_bind_group(0, camera_bg, &[]);
1196                    pass.set_bind_group(1, &sub_hl.sprite_bind_group, &[]);
1197                    pass.set_vertex_buffer(0, sub_hl.sprite_vertex_buf.slice(..));
1198                    pass.draw(0..6, 0..sub_hl.sprite_point_count);
1199                }
1200            }
1201        }
1202
1203        // -----------------------------------------------------------------------
1204        // OIT pass: render transparent items into accum + reveal textures.
1205        // Completely skipped when no transparent items exist (zero overhead).
1206        // -----------------------------------------------------------------------
1207        let has_transparent = if self.use_instancing && !self.instanced_batches.is_empty() {
1208            self.instanced_batches.iter().any(|b| b.is_transparent)
1209        } else {
1210            scene_items
1211                .iter()
1212                .any(|i| i.visible && i.material.opacity < 1.0)
1213        };
1214
1215        if has_transparent {
1216            // OIT targets already allocated in the pre-pass above.
1217            if let (Some(accum_view), Some(reveal_view)) = (
1218                slot_hdr.oit_accum_view.as_ref(),
1219                slot_hdr.oit_reveal_view.as_ref(),
1220            ) {
1221                let hdr_depth_view = &slot_hdr.hdr_depth_view;
1222                // Clear accum to (0,0,0,0), reveal to 1.0 (no contribution yet).
1223                let mut oit_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1224                    label: Some("oit_pass"),
1225                    color_attachments: &[
1226                        Some(wgpu::RenderPassColorAttachment {
1227                            view: accum_view,
1228                            resolve_target: None,
1229                            ops: wgpu::Operations {
1230                                load: wgpu::LoadOp::Clear(wgpu::Color {
1231                                    r: 0.0,
1232                                    g: 0.0,
1233                                    b: 0.0,
1234                                    a: 0.0,
1235                                }),
1236                                store: wgpu::StoreOp::Store,
1237                            },
1238                            depth_slice: None,
1239                        }),
1240                        Some(wgpu::RenderPassColorAttachment {
1241                            view: reveal_view,
1242                            resolve_target: None,
1243                            ops: wgpu::Operations {
1244                                load: wgpu::LoadOp::Clear(wgpu::Color {
1245                                    r: 1.0,
1246                                    g: 1.0,
1247                                    b: 1.0,
1248                                    a: 1.0,
1249                                }),
1250                                store: wgpu::StoreOp::Store,
1251                            },
1252                            depth_slice: None,
1253                        }),
1254                    ],
1255                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1256                        view: hdr_depth_view,
1257                        depth_ops: Some(wgpu::Operations {
1258                            load: wgpu::LoadOp::Load, // reuse opaque depth
1259                            store: wgpu::StoreOp::Store,
1260                        }),
1261                        stencil_ops: None,
1262                    }),
1263                    timestamp_writes: None,
1264                    occlusion_query_set: None,
1265                });
1266
1267                oit_pass.set_bind_group(0, camera_bg, &[]);
1268
1269                if self.use_instancing && !self.instanced_batches.is_empty() {
1270                    if let Some(ref pipeline) = self.resources.oit_instanced_pipeline {
1271                        oit_pass.set_pipeline(pipeline);
1272                        for batch in &self.instanced_batches {
1273                            if !batch.is_transparent {
1274                                continue;
1275                            }
1276                            let Some(mesh) = self
1277                                .resources
1278                                .mesh_store
1279                                .get(batch.mesh_id)
1280                            else {
1281                                continue;
1282                            };
1283                            let mat_key = (
1284                                batch.texture_id.unwrap_or(u64::MAX),
1285                                batch.normal_map_id.unwrap_or(u64::MAX),
1286                                batch.ao_map_id.unwrap_or(u64::MAX),
1287                            );
1288                            let Some(inst_tex_bg) =
1289                                self.resources.instance_bind_groups.get(&mat_key)
1290                            else {
1291                                continue;
1292                            };
1293                            oit_pass.set_bind_group(1, inst_tex_bg, &[]);
1294                            oit_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1295                            oit_pass.set_index_buffer(
1296                                mesh.index_buffer.slice(..),
1297                                wgpu::IndexFormat::Uint32,
1298                            );
1299                            oit_pass.draw_indexed(
1300                                0..mesh.index_count,
1301                                0,
1302                                batch.instance_offset..batch.instance_offset + batch.instance_count,
1303                            );
1304                        }
1305                    }
1306                } else if let Some(ref pipeline) = self.resources.oit_pipeline {
1307                    oit_pass.set_pipeline(pipeline);
1308                    for item in scene_items {
1309                        if !item.visible || item.material.opacity >= 1.0 {
1310                            continue;
1311                        }
1312                        let Some(mesh) = self
1313                            .resources
1314                            .mesh_store
1315                            .get(item.mesh_id)
1316                        else {
1317                            continue;
1318                        };
1319                        oit_pass.set_bind_group(1, &mesh.object_bind_group, &[]);
1320                        oit_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1321                        oit_pass.set_index_buffer(
1322                            mesh.index_buffer.slice(..),
1323                            wgpu::IndexFormat::Uint32,
1324                        );
1325                        oit_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1326                    }
1327                }
1328            }
1329        }
1330
1331        // -----------------------------------------------------------------------
1332        // OIT composite pass: blend accum/reveal into HDR buffer.
1333        // Only executes when transparent items were present.
1334        // -----------------------------------------------------------------------
1335        if has_transparent {
1336            if let (Some(pipeline), Some(bg)) = (
1337                self.resources.oit_composite_pipeline.as_ref(),
1338                slot_hdr.oit_composite_bind_group.as_ref(),
1339            ) {
1340                let hdr_view = &slot_hdr.hdr_view;
1341                let mut composite_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1342                    label: Some("oit_composite_pass"),
1343                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1344                        view: hdr_view,
1345                        resolve_target: None,
1346                        ops: wgpu::Operations {
1347                            load: wgpu::LoadOp::Load,
1348                            store: wgpu::StoreOp::Store,
1349                        },
1350                        depth_slice: None,
1351                    })],
1352                    depth_stencil_attachment: None,
1353                    timestamp_writes: None,
1354                    occlusion_query_set: None,
1355                });
1356                composite_pass.set_pipeline(pipeline);
1357                composite_pass.set_bind_group(0, bg, &[]);
1358                composite_pass.draw(0..3, 0..1);
1359            }
1360        }
1361
1362        // -----------------------------------------------------------------------
1363        // Outline composite pass (HDR path): blit offscreen outline onto hdr_view.
1364        // Runs after the HDR scene pass (which has depth+stencil) in a separate
1365        // pass with no depth attachment, so the composite pipeline is compatible.
1366        // -----------------------------------------------------------------------
1367        if !slot.outline_object_buffers.is_empty() {
1368            // Prefer the HDR-format pipeline; fall back to LDR single-sample.
1369            let hdr_pipeline = self
1370                .resources
1371                .outline_composite_pipeline_hdr
1372                .as_ref()
1373                .or(self.resources.outline_composite_pipeline_single.as_ref());
1374            if let Some(pipeline) = hdr_pipeline {
1375                let bg = &slot_hdr.outline_composite_bind_group;
1376                let hdr_view = &slot_hdr.hdr_view;
1377                let hdr_depth_view = &slot_hdr.hdr_depth_view;
1378                let mut outline_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1379                    label: Some("hdr_outline_composite_pass"),
1380                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1381                        view: hdr_view,
1382                        resolve_target: None,
1383                        ops: wgpu::Operations {
1384                            load: wgpu::LoadOp::Load,
1385                            store: wgpu::StoreOp::Store,
1386                        },
1387                        depth_slice: None,
1388                    })],
1389                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1390                        view: hdr_depth_view,
1391                        depth_ops: Some(wgpu::Operations {
1392                            load: wgpu::LoadOp::Load,
1393                            store: wgpu::StoreOp::Store,
1394                        }),
1395                        stencil_ops: None,
1396                    }),
1397                    timestamp_writes: None,
1398                    occlusion_query_set: None,
1399                });
1400                outline_pass.set_pipeline(pipeline);
1401                outline_pass.set_bind_group(0, bg, &[]);
1402                outline_pass.draw(0..3, 0..1);
1403            }
1404        }
1405
1406        // Phase 5 : effect throttling. When over budget and the policy permits it,
1407        // skip non-essential effect passes (SSAO, contact shadows, bloom).
1408        let throttle_effects = self.last_stats.missed_budget
1409            && self.performance_policy.allow_effect_throttling;
1410
1411        // -----------------------------------------------------------------------
1412        // SSAO pass.
1413        // -----------------------------------------------------------------------
1414        if pp.ssao && !throttle_effects {
1415            if let Some(ssao_pipeline) = &self.resources.ssao_pipeline {
1416                {
1417                    let mut ssao_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1418                        label: Some("ssao_pass"),
1419                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1420                            view: &slot_hdr.ssao_view,
1421                            resolve_target: None,
1422                            ops: wgpu::Operations {
1423                                load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
1424                                store: wgpu::StoreOp::Store,
1425                            },
1426                            depth_slice: None,
1427                        })],
1428                        depth_stencil_attachment: None,
1429                        timestamp_writes: None,
1430                        occlusion_query_set: None,
1431                    });
1432                    ssao_pass.set_pipeline(ssao_pipeline);
1433                    ssao_pass.set_bind_group(0, &slot_hdr.ssao_bg, &[]);
1434                    ssao_pass.draw(0..3, 0..1);
1435                }
1436
1437                // SSAO blur pass.
1438                if let Some(ssao_blur_pipeline) = &self.resources.ssao_blur_pipeline {
1439                    let mut ssao_blur_pass =
1440                        encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1441                            label: Some("ssao_blur_pass"),
1442                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1443                                view: &slot_hdr.ssao_blur_view,
1444                                resolve_target: None,
1445                                ops: wgpu::Operations {
1446                                    load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
1447                                    store: wgpu::StoreOp::Store,
1448                                },
1449                                depth_slice: None,
1450                            })],
1451                            depth_stencil_attachment: None,
1452                            timestamp_writes: None,
1453                            occlusion_query_set: None,
1454                        });
1455                    ssao_blur_pass.set_pipeline(ssao_blur_pipeline);
1456                    ssao_blur_pass.set_bind_group(0, &slot_hdr.ssao_blur_bg, &[]);
1457                    ssao_blur_pass.draw(0..3, 0..1);
1458                }
1459            }
1460        }
1461
1462        // -----------------------------------------------------------------------
1463        // Contact shadow pass.
1464        // -----------------------------------------------------------------------
1465        if pp.contact_shadows && !throttle_effects {
1466            if let Some(cs_pipeline) = &self.resources.contact_shadow_pipeline {
1467                let mut cs_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1468                    label: Some("contact_shadow_pass"),
1469                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1470                        view: &slot_hdr.contact_shadow_view,
1471                        resolve_target: None,
1472                        depth_slice: None,
1473                        ops: wgpu::Operations {
1474                            load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
1475                            store: wgpu::StoreOp::Store,
1476                        },
1477                    })],
1478                    depth_stencil_attachment: None,
1479                    timestamp_writes: None,
1480                    occlusion_query_set: None,
1481                });
1482                cs_pass.set_pipeline(cs_pipeline);
1483                cs_pass.set_bind_group(0, &slot_hdr.contact_shadow_bg, &[]);
1484                cs_pass.draw(0..3, 0..1);
1485            }
1486        }
1487
1488        // -----------------------------------------------------------------------
1489        // Bloom passes.
1490        // -----------------------------------------------------------------------
1491        if pp.bloom && !throttle_effects {
1492            // Threshold pass: extract bright pixels into bloom_threshold_texture.
1493            if let Some(bloom_threshold_pipeline) = &self.resources.bloom_threshold_pipeline {
1494                {
1495                    let mut threshold_pass =
1496                        encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1497                            label: Some("bloom_threshold_pass"),
1498                            color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1499                                view: &slot_hdr.bloom_threshold_view,
1500                                resolve_target: None,
1501                                ops: wgpu::Operations {
1502                                    load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1503                                    store: wgpu::StoreOp::Store,
1504                                },
1505                                depth_slice: None,
1506                            })],
1507                            depth_stencil_attachment: None,
1508                            timestamp_writes: None,
1509                            occlusion_query_set: None,
1510                        });
1511                    threshold_pass.set_pipeline(bloom_threshold_pipeline);
1512                    threshold_pass.set_bind_group(0, &slot_hdr.bloom_threshold_bg, &[]);
1513                    threshold_pass.draw(0..3, 0..1);
1514                }
1515
1516                // 4 ping-pong H+V blur passes for a wide glow.
1517                // Pass 1: threshold -> ping -> pong. Passes 2-4: pong -> ping -> pong.
1518                if let Some(blur_pipeline) = &self.resources.bloom_blur_pipeline {
1519                    let blur_h_bg = &slot_hdr.bloom_blur_h_bg;
1520                    let blur_h_pong_bg = &slot_hdr.bloom_blur_h_pong_bg;
1521                    let blur_v_bg = &slot_hdr.bloom_blur_v_bg;
1522                    let bloom_ping_view = &slot_hdr.bloom_ping_view;
1523                    let bloom_pong_view = &slot_hdr.bloom_pong_view;
1524                    const BLUR_ITERATIONS: usize = 4;
1525                    for i in 0..BLUR_ITERATIONS {
1526                        // H pass: pass 0 reads threshold, subsequent passes read pong.
1527                        let h_bg = if i == 0 { blur_h_bg } else { blur_h_pong_bg };
1528                        {
1529                            let mut h_pass =
1530                                encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1531                                    label: Some("bloom_blur_h_pass"),
1532                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1533                                        view: bloom_ping_view,
1534                                        resolve_target: None,
1535                                        ops: wgpu::Operations {
1536                                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1537                                            store: wgpu::StoreOp::Store,
1538                                        },
1539                                        depth_slice: None,
1540                                    })],
1541                                    depth_stencil_attachment: None,
1542                                    timestamp_writes: None,
1543                                    occlusion_query_set: None,
1544                                });
1545                            h_pass.set_pipeline(blur_pipeline);
1546                            h_pass.set_bind_group(0, h_bg, &[]);
1547                            h_pass.draw(0..3, 0..1);
1548                        }
1549                        // V pass: ping -> pong.
1550                        {
1551                            let mut v_pass =
1552                                encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1553                                    label: Some("bloom_blur_v_pass"),
1554                                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1555                                        view: bloom_pong_view,
1556                                        resolve_target: None,
1557                                        ops: wgpu::Operations {
1558                                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1559                                            store: wgpu::StoreOp::Store,
1560                                        },
1561                                        depth_slice: None,
1562                                    })],
1563                                    depth_stencil_attachment: None,
1564                                    timestamp_writes: None,
1565                                    occlusion_query_set: None,
1566                                });
1567                            v_pass.set_pipeline(blur_pipeline);
1568                            v_pass.set_bind_group(0, blur_v_bg, &[]);
1569                            v_pass.draw(0..3, 0..1);
1570                        }
1571                    }
1572                }
1573            }
1574        }
1575
1576        // -----------------------------------------------------------------------
1577        // Tone map pass: HDR + bloom + AO -> (fxaa_texture if FXAA) or output_view.
1578        // -----------------------------------------------------------------------
1579        let use_fxaa = pp.fxaa;
1580        if let Some(tone_map_pipeline) = &self.resources.tone_map_pipeline {
1581            let tone_target: &wgpu::TextureView = if use_fxaa {
1582                &slot_hdr.fxaa_view
1583            } else {
1584                output_view
1585            };
1586            let mut tone_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1587                label: Some("tone_map_pass"),
1588                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1589                    view: tone_target,
1590                    resolve_target: None,
1591                    ops: wgpu::Operations {
1592                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1593                        store: wgpu::StoreOp::Store,
1594                    },
1595                    depth_slice: None,
1596                })],
1597                depth_stencil_attachment: None,
1598                timestamp_writes: None,
1599                occlusion_query_set: None,
1600            });
1601            tone_pass.set_pipeline(tone_map_pipeline);
1602            tone_pass.set_bind_group(0, &slot_hdr.tone_map_bind_group, &[]);
1603            tone_pass.draw(0..3, 0..1);
1604        }
1605
1606        // -----------------------------------------------------------------------
1607        // FXAA pass: fxaa_texture -> output_view (only when FXAA is enabled).
1608        // -----------------------------------------------------------------------
1609        if use_fxaa {
1610            if let Some(fxaa_pipeline) = &self.resources.fxaa_pipeline {
1611                let mut fxaa_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1612                    label: Some("fxaa_pass"),
1613                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1614                        view: output_view,
1615                        resolve_target: None,
1616                        ops: wgpu::Operations {
1617                            load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
1618                            store: wgpu::StoreOp::Store,
1619                        },
1620                        depth_slice: None,
1621                    })],
1622                    depth_stencil_attachment: None,
1623                    timestamp_writes: None,
1624                    occlusion_query_set: None,
1625                });
1626                fxaa_pass.set_pipeline(fxaa_pipeline);
1627                fxaa_pass.set_bind_group(0, &slot_hdr.fxaa_bind_group, &[]);
1628                fxaa_pass.draw(0..3, 0..1);
1629            }
1630        }
1631
1632        // Grid pass (HDR path): draw the existing analytical grid on the final
1633        // output after tone mapping / FXAA, reusing the scene depth buffer so
1634        // scene geometry still occludes the grid exactly as in the LDR path.
1635        if frame.viewport.show_grid {
1636            let slot = &self.viewport_slots[vp_idx];
1637            let slot_hdr = slot.hdr.as_ref().unwrap();
1638            let grid_bg = &slot.grid_bind_group;
1639            let mut grid_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1640                label: Some("hdr_grid_pass"),
1641                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1642                    view: output_view,
1643                    resolve_target: None,
1644                    ops: wgpu::Operations {
1645                        load: wgpu::LoadOp::Load,
1646                        store: wgpu::StoreOp::Store,
1647                    },
1648                    depth_slice: None,
1649                })],
1650                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1651                    view: &slot_hdr.hdr_depth_view,
1652                    depth_ops: Some(wgpu::Operations {
1653                        load: wgpu::LoadOp::Load,
1654                        store: wgpu::StoreOp::Store,
1655                    }),
1656                    stencil_ops: None,
1657                }),
1658                timestamp_writes: None,
1659                occlusion_query_set: None,
1660            });
1661            grid_pass.set_pipeline(&self.resources.grid_pipeline);
1662            grid_pass.set_bind_group(0, grid_bg, &[]);
1663            grid_pass.draw(0..3, 0..1);
1664        }
1665
1666        // Ground plane pass (HDR path): drawn after grid, before editor overlays.
1667        // Uses the scene depth buffer for correct occlusion against geometry.
1668        if !matches!(
1669            frame.effects.ground_plane.mode,
1670            crate::renderer::types::GroundPlaneMode::None
1671        ) {
1672            let slot = &self.viewport_slots[vp_idx];
1673            let slot_hdr = slot.hdr.as_ref().unwrap();
1674            let mut gp_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1675                label: Some("hdr_ground_plane_pass"),
1676                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1677                    view: output_view,
1678                    resolve_target: None,
1679                    ops: wgpu::Operations {
1680                        load: wgpu::LoadOp::Load,
1681                        store: wgpu::StoreOp::Store,
1682                    },
1683                    depth_slice: None,
1684                })],
1685                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1686                    view: &slot_hdr.hdr_depth_view,
1687                    depth_ops: Some(wgpu::Operations {
1688                        load: wgpu::LoadOp::Load,
1689                        store: wgpu::StoreOp::Store,
1690                    }),
1691                    stencil_ops: None,
1692                }),
1693                timestamp_writes: None,
1694                occlusion_query_set: None,
1695            });
1696            gp_pass.set_pipeline(&self.resources.ground_plane_pipeline);
1697            gp_pass.set_bind_group(0, &self.resources.ground_plane_bind_group, &[]);
1698            gp_pass.draw(0..3, 0..1);
1699        }
1700
1701        // Editor overlay pass (HDR path): draw viewport/editor overlays on the
1702        // final output after tone mapping / FXAA, reusing the scene depth
1703        // buffer so depth-tested helpers still behave correctly.
1704        {
1705            let slot = &self.viewport_slots[vp_idx];
1706            let slot_hdr = slot.hdr.as_ref().unwrap();
1707            let has_editor_overlays = (frame.interaction.gizmo_model.is_some()
1708                && slot.gizmo_index_count > 0)
1709                || !slot.constraint_line_buffers.is_empty()
1710                || !slot.clip_plane_fill_buffers.is_empty()
1711                || !slot.clip_plane_line_buffers.is_empty()
1712                || !slot.xray_object_buffers.is_empty();
1713            if has_editor_overlays {
1714                let camera_bg = &slot.camera_bind_group;
1715                let mut overlay_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1716                    label: Some("hdr_editor_overlay_pass"),
1717                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1718                        view: output_view,
1719                        resolve_target: None,
1720                        ops: wgpu::Operations {
1721                            load: wgpu::LoadOp::Load,
1722                            store: wgpu::StoreOp::Store,
1723                        },
1724                        depth_slice: None,
1725                    })],
1726                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1727                        view: &slot_hdr.hdr_depth_view,
1728                        depth_ops: Some(wgpu::Operations {
1729                            load: wgpu::LoadOp::Load,
1730                            store: wgpu::StoreOp::Discard,
1731                        }),
1732                        stencil_ops: None,
1733                    }),
1734                    timestamp_writes: None,
1735                    occlusion_query_set: None,
1736                });
1737
1738                if frame.interaction.gizmo_model.is_some() && slot.gizmo_index_count > 0 {
1739                    overlay_pass.set_pipeline(&self.resources.gizmo_pipeline);
1740                    overlay_pass.set_bind_group(0, camera_bg, &[]);
1741                    overlay_pass.set_bind_group(1, &slot.gizmo_bind_group, &[]);
1742                    overlay_pass.set_vertex_buffer(0, slot.gizmo_vertex_buffer.slice(..));
1743                    overlay_pass.set_index_buffer(
1744                        slot.gizmo_index_buffer.slice(..),
1745                        wgpu::IndexFormat::Uint32,
1746                    );
1747                    overlay_pass.draw_indexed(0..slot.gizmo_index_count, 0, 0..1);
1748                }
1749
1750                if !slot.constraint_line_buffers.is_empty() {
1751                    overlay_pass.set_pipeline(&self.resources.overlay_line_pipeline);
1752                    overlay_pass.set_bind_group(0, camera_bg, &[]);
1753                    for (vbuf, ibuf, index_count, _ubuf, bg) in &slot.constraint_line_buffers {
1754                        overlay_pass.set_bind_group(1, bg, &[]);
1755                        overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
1756                        overlay_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
1757                        overlay_pass.draw_indexed(0..*index_count, 0, 0..1);
1758                    }
1759                }
1760
1761                if !slot.clip_plane_fill_buffers.is_empty() {
1762                    overlay_pass.set_pipeline(&self.resources.overlay_pipeline);
1763                    overlay_pass.set_bind_group(0, camera_bg, &[]);
1764                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.clip_plane_fill_buffers {
1765                        overlay_pass.set_bind_group(1, bg, &[]);
1766                        overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
1767                        overlay_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
1768                        overlay_pass.draw_indexed(0..*idx_count, 0, 0..1);
1769                    }
1770                }
1771
1772                if !slot.clip_plane_line_buffers.is_empty() {
1773                    overlay_pass.set_pipeline(&self.resources.overlay_line_pipeline);
1774                    overlay_pass.set_bind_group(0, camera_bg, &[]);
1775                    for (vbuf, ibuf, idx_count, _ubuf, bg) in &slot.clip_plane_line_buffers {
1776                        overlay_pass.set_bind_group(1, bg, &[]);
1777                        overlay_pass.set_vertex_buffer(0, vbuf.slice(..));
1778                        overlay_pass.set_index_buffer(ibuf.slice(..), wgpu::IndexFormat::Uint32);
1779                        overlay_pass.draw_indexed(0..*idx_count, 0, 0..1);
1780                    }
1781                }
1782
1783                if !slot.xray_object_buffers.is_empty() {
1784                    overlay_pass.set_pipeline(&self.resources.xray_pipeline);
1785                    overlay_pass.set_bind_group(0, camera_bg, &[]);
1786                    for (mesh_id, _buf, bg) in &slot.xray_object_buffers {
1787                        let Some(mesh) = self
1788                            .resources
1789                            .mesh_store
1790                            .get(*mesh_id)
1791                        else {
1792                            continue;
1793                        };
1794                        overlay_pass.set_bind_group(1, bg, &[]);
1795                        overlay_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
1796                        overlay_pass.set_index_buffer(
1797                            mesh.index_buffer.slice(..),
1798                            wgpu::IndexFormat::Uint32,
1799                        );
1800                        overlay_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
1801                    }
1802                }
1803            }
1804        }
1805
1806        // Axes indicator pass (HDR path): draw in screen space on the final
1807        // output after tone mapping / FXAA so it stays visible in PBR mode.
1808        if frame.viewport.show_axes_indicator {
1809            let slot = &self.viewport_slots[vp_idx];
1810            if slot.axes_vertex_count > 0 {
1811                let slot_hdr = slot.hdr.as_ref().unwrap();
1812                let mut axes_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1813                    label: Some("hdr_axes_pass"),
1814                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1815                        view: output_view,
1816                        resolve_target: None,
1817                        ops: wgpu::Operations {
1818                            load: wgpu::LoadOp::Load,
1819                            store: wgpu::StoreOp::Store,
1820                        },
1821                        depth_slice: None,
1822                    })],
1823                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1824                        view: &slot_hdr.hdr_depth_view,
1825                        depth_ops: Some(wgpu::Operations {
1826                            load: wgpu::LoadOp::Load,
1827                            store: wgpu::StoreOp::Discard,
1828                        }),
1829                        stencil_ops: None,
1830                    }),
1831                    timestamp_writes: None,
1832                    occlusion_query_set: None,
1833                });
1834                axes_pass.set_pipeline(&self.resources.axes_pipeline);
1835                axes_pass.set_vertex_buffer(0, slot.axes_vertex_buffer.slice(..));
1836                axes_pass.draw(0..slot.axes_vertex_count, 0..1);
1837            }
1838        }
1839
1840        // Phase 10B / Phase 12 : screen-space image overlay pass (HDR path).
1841        // Drawn after axes so overlays are always on top of everything.
1842        // Regular items use depth_compare: Always; depth-composite items use LessEqual.
1843        if !self.screen_image_gpu_data.is_empty() {
1844            if let Some(overlay_pipeline) = &self.resources.screen_image_pipeline {
1845                let slot_hdr = self.viewport_slots[vp_idx].hdr.as_ref().unwrap();
1846                let dc_pipeline = self.resources.screen_image_dc_pipeline.as_ref();
1847                let mut img_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1848                    label: Some("screen_image_pass"),
1849                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1850                        view: output_view,
1851                        resolve_target: None,
1852                        ops: wgpu::Operations {
1853                            load: wgpu::LoadOp::Load,
1854                            store: wgpu::StoreOp::Store,
1855                        },
1856                        depth_slice: None,
1857                    })],
1858                    depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1859                        view: &slot_hdr.hdr_depth_view,
1860                        depth_ops: Some(wgpu::Operations {
1861                            load: wgpu::LoadOp::Load,
1862                            store: wgpu::StoreOp::Discard,
1863                        }),
1864                        stencil_ops: None,
1865                    }),
1866                    timestamp_writes: None,
1867                    occlusion_query_set: None,
1868                });
1869                for gpu in &self.screen_image_gpu_data {
1870                    if let (Some(dc_bg), Some(dc_pipe)) = (&gpu.depth_bind_group, dc_pipeline) {
1871                        img_pass.set_pipeline(dc_pipe);
1872                        img_pass.set_bind_group(0, dc_bg, &[]);
1873                    } else {
1874                        img_pass.set_pipeline(overlay_pipeline);
1875                        img_pass.set_bind_group(0, &gpu.bind_group, &[]);
1876                    }
1877                    img_pass.draw(0..6, 0..1);
1878                }
1879            }
1880        }
1881
1882        // Overlay labels, scalar bars, rulers, and overlay images (HDR path): drawn last.
1883        let has_overlay = self.label_gpu_data.is_some()
1884            || self.scalar_bar_gpu_data.is_some()
1885            || self.ruler_gpu_data.is_some()
1886            || !self.overlay_image_gpu_data.is_empty();
1887        if has_overlay {
1888            let hdr_depth_view =
1889                &self.viewport_slots[vp_idx].hdr.as_ref().unwrap().hdr_depth_view;
1890            let mut overlay_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
1891                label: Some("overlay_pass"),
1892                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
1893                    view: output_view,
1894                    resolve_target: None,
1895                    ops: wgpu::Operations {
1896                        load: wgpu::LoadOp::Load,
1897                        store: wgpu::StoreOp::Store,
1898                    },
1899                    depth_slice: None,
1900                })],
1901                depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
1902                    view: hdr_depth_view,
1903                    depth_ops: Some(wgpu::Operations {
1904                        load: wgpu::LoadOp::Load,
1905                        store: wgpu::StoreOp::Discard,
1906                    }),
1907                    stencil_ops: None,
1908                }),
1909                timestamp_writes: None,
1910                occlusion_query_set: None,
1911            });
1912            if let Some(pipeline) = &self.resources.overlay_text_pipeline {
1913                overlay_pass.set_pipeline(pipeline);
1914                if let Some(ref ld) = self.label_gpu_data {
1915                    overlay_pass.set_bind_group(0, &ld.bind_group, &[]);
1916                    overlay_pass.set_vertex_buffer(0, ld.vertex_buf.slice(..));
1917                    overlay_pass.draw(0..ld.vertex_count, 0..1);
1918                }
1919                if let Some(ref sb) = self.scalar_bar_gpu_data {
1920                    overlay_pass.set_bind_group(0, &sb.bind_group, &[]);
1921                    overlay_pass.set_vertex_buffer(0, sb.vertex_buf.slice(..));
1922                    overlay_pass.draw(0..sb.vertex_count, 0..1);
1923                }
1924                if let Some(ref rd) = self.ruler_gpu_data {
1925                    overlay_pass.set_bind_group(0, &rd.bind_group, &[]);
1926                    overlay_pass.set_vertex_buffer(0, rd.vertex_buf.slice(..));
1927                    overlay_pass.draw(0..rd.vertex_count, 0..1);
1928                }
1929            }
1930            // Phase 7 : overlay images drawn last inside the overlay pass.
1931            if !self.overlay_image_gpu_data.is_empty() {
1932                if let Some(pipeline) = &self.resources.screen_image_pipeline {
1933                    overlay_pass.set_pipeline(pipeline);
1934                    for gpu in &self.overlay_image_gpu_data {
1935                        overlay_pass.set_bind_group(0, &gpu.bind_group, &[]);
1936                        overlay_pass.draw(0..6, 0..1);
1937                    }
1938                }
1939            }
1940        }
1941
1942        // Phase 4 : resolve timestamp queries -> staging buffer (HDR path).
1943        if let (Some(qs), Some(res_buf), Some(stg_buf)) = (
1944            self.ts_query_set.as_ref(),
1945            self.ts_resolve_buf.as_ref(),
1946            self.ts_staging_buf.as_ref(),
1947        ) {
1948            encoder.resolve_query_set(qs, 0..2, res_buf, 0);
1949            encoder.copy_buffer_to_buffer(res_buf, 0, stg_buf, 0, 16);
1950            self.ts_needs_readback = true;
1951        }
1952
1953        encoder.finish()
1954    }
1955
1956    /// Render a frame to an offscreen texture and return raw RGBA bytes.
1957    ///
1958    /// Creates a temporary [`wgpu::Texture`] render target of the given dimensions,
1959    /// runs all render passes (shadow, scene, post-processing) into it via
1960    /// [`render()`](Self::render), then copies the result back to CPU memory.
1961    ///
1962    /// No OS window or [`wgpu::Surface`] is required. The caller is responsible for
1963    /// initialising the wgpu adapter with `compatible_surface: None` and for
1964    /// constructing a valid [`FrameData`] (including `viewport_size` matching
1965    /// `width`/`height`).
1966    ///
1967    /// Returns `width * height * 4` bytes in RGBA8 layout. The caller encodes to
1968    /// PNG/EXR independently : no image codec dependency in this crate.
1969    pub fn render_offscreen(
1970        &mut self,
1971        device: &wgpu::Device,
1972        queue: &wgpu::Queue,
1973        frame: &FrameData,
1974        width: u32,
1975        height: u32,
1976    ) -> Vec<u8> {
1977        // 1. Create offscreen texture with RENDER_ATTACHMENT | COPY_SRC usage.
1978        let target_format = self.resources.target_format;
1979        let offscreen_texture = device.create_texture(&wgpu::TextureDescriptor {
1980            label: Some("offscreen_target"),
1981            size: wgpu::Extent3d {
1982                width: width.max(1),
1983                height: height.max(1),
1984                depth_or_array_layers: 1,
1985            },
1986            mip_level_count: 1,
1987            sample_count: 1,
1988            dimension: wgpu::TextureDimension::D2,
1989            format: target_format,
1990            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
1991            view_formats: &[],
1992        });
1993
1994        // 2. Create a texture view for rendering into.
1995        let output_view = offscreen_texture.create_view(&wgpu::TextureViewDescriptor::default());
1996
1997        // 3. render() calls ensure_viewport_hdr which provides the depth-stencil buffer
1998        //    for both LDR and HDR paths, so no separate ensure_outline_target is needed.
1999
2000        // 4. Render the scene into the offscreen texture.
2001        //    The caller must set `frame.camera.viewport_size` to `[width as f32, height as f32]`
2002        //    and `frame.camera.render_camera.aspect` to `width as f32 / height as f32`
2003        //    for correct HDR target allocation and scissor rects.
2004        let cmd_buf = self.render(device, queue, &output_view, frame);
2005        queue.submit(std::iter::once(cmd_buf));
2006
2007        // 5. Copy texture -> staging buffer (wgpu requires row alignment to 256 bytes).
2008        let bytes_per_pixel = 4u32;
2009        let unpadded_row = width * bytes_per_pixel;
2010        let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
2011        let padded_row = (unpadded_row + align - 1) & !(align - 1);
2012        let buffer_size = (padded_row * height.max(1)) as u64;
2013
2014        let staging_buf = device.create_buffer(&wgpu::BufferDescriptor {
2015            label: Some("offscreen_staging"),
2016            size: buffer_size,
2017            usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
2018            mapped_at_creation: false,
2019        });
2020
2021        let mut copy_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
2022            label: Some("offscreen_copy_encoder"),
2023        });
2024        copy_encoder.copy_texture_to_buffer(
2025            wgpu::TexelCopyTextureInfo {
2026                texture: &offscreen_texture,
2027                mip_level: 0,
2028                origin: wgpu::Origin3d::ZERO,
2029                aspect: wgpu::TextureAspect::All,
2030            },
2031            wgpu::TexelCopyBufferInfo {
2032                buffer: &staging_buf,
2033                layout: wgpu::TexelCopyBufferLayout {
2034                    offset: 0,
2035                    bytes_per_row: Some(padded_row),
2036                    rows_per_image: Some(height.max(1)),
2037                },
2038            },
2039            wgpu::Extent3d {
2040                width: width.max(1),
2041                height: height.max(1),
2042                depth_or_array_layers: 1,
2043            },
2044        );
2045        queue.submit(std::iter::once(copy_encoder.finish()));
2046
2047        // 6. Map buffer and extract tightly-packed RGBA pixels.
2048        let (tx, rx) = std::sync::mpsc::channel();
2049        staging_buf
2050            .slice(..)
2051            .map_async(wgpu::MapMode::Read, move |result| {
2052                let _ = tx.send(result);
2053            });
2054        device
2055            .poll(wgpu::PollType::Wait {
2056                submission_index: None,
2057                timeout: Some(std::time::Duration::from_secs(5)),
2058            })
2059            .unwrap();
2060        let _ = rx.recv().unwrap_or(Err(wgpu::BufferAsyncError));
2061
2062        let mut pixels: Vec<u8> = Vec::with_capacity((width * height * 4) as usize);
2063        {
2064            let mapped = staging_buf.slice(..).get_mapped_range();
2065            let data: &[u8] = &mapped;
2066            if padded_row == unpadded_row {
2067                // No padding : copy entire slice directly.
2068                pixels.extend_from_slice(data);
2069            } else {
2070                // Strip row padding.
2071                for row in 0..height as usize {
2072                    let start = row * padded_row as usize;
2073                    let end = start + unpadded_row as usize;
2074                    pixels.extend_from_slice(&data[start..end]);
2075                }
2076            }
2077        }
2078        staging_buf.unmap();
2079
2080        // 7. Swizzle BGRA -> RGBA if the format stores bytes in BGRA order.
2081        let is_bgra = matches!(
2082            target_format,
2083            wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
2084        );
2085        if is_bgra {
2086            for pixel in pixels.chunks_exact_mut(4) {
2087                pixel.swap(0, 2); // B ↔ R
2088            }
2089        }
2090
2091        pixels
2092    }
2093}