Skip to main content

viewport_lib/resources/scivis/
image.rs

1use super::*;
2use crate::resources::Vertex;
3
4impl DeviceResources {
5    // -------------------------------------------------------------------------
6    // 2D Image Slice representation
7    // -------------------------------------------------------------------------
8
9    /// Lazily create the image slice render pipeline.
10    ///
11    /// No-op if already created. Called from `prepare()` when `frame.scene.image_slices` is non-empty.
12    pub(crate) fn ensure_image_slice_pipeline(&mut self, device: &wgpu::Device) {
13        if self.image_slice.pipeline.is_some() {
14            return;
15        }
16
17        let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
18            label: Some("image_slice_bgl"),
19            entries: &[
20                // binding 0: ImageSliceUniform
21                wgpu::BindGroupLayoutEntry {
22                    binding: 0,
23                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
24                    ty: wgpu::BindingType::Buffer {
25                        ty: wgpu::BufferBindingType::Uniform,
26                        has_dynamic_offset: false,
27                        min_binding_size: None,
28                    },
29                    count: None,
30                },
31                // binding 1: texture_3d<f32>: R32Float is not filterable on most hardware,
32                // so declare as non-filterable and use a NonFiltering sampler.
33                wgpu::BindGroupLayoutEntry {
34                    binding: 1,
35                    visibility: wgpu::ShaderStages::FRAGMENT,
36                    ty: wgpu::BindingType::Texture {
37                        multisampled: false,
38                        view_dimension: wgpu::TextureViewDimension::D3,
39                        sample_type: wgpu::TextureSampleType::Float { filterable: false },
40                    },
41                    count: None,
42                },
43                // binding 2: vol_sampler (non-filtering nearest, matches R32Float)
44                wgpu::BindGroupLayoutEntry {
45                    binding: 2,
46                    visibility: wgpu::ShaderStages::FRAGMENT,
47                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
48                    count: None,
49                },
50                // binding 3: lut_tex (colourmap texture_2d)
51                wgpu::BindGroupLayoutEntry {
52                    binding: 3,
53                    visibility: wgpu::ShaderStages::FRAGMENT,
54                    ty: wgpu::BindingType::Texture {
55                        multisampled: false,
56                        view_dimension: wgpu::TextureViewDimension::D2,
57                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
58                    },
59                    count: None,
60                },
61                // binding 4: lut_sampler (linear)
62                wgpu::BindGroupLayoutEntry {
63                    binding: 4,
64                    visibility: wgpu::ShaderStages::FRAGMENT,
65                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
66                    count: None,
67                },
68            ],
69        });
70
71        let shader = crate::resources::builders::wgsl_module(
72            device,
73            "image_slice_shader",
74            crate::resources::builders::wgsl_source!("image_slice"),
75        );
76
77        let layout = crate::resources::builders::standard_scene_layout(
78            device,
79            "image_slice_pipeline_layout",
80            &self.camera_bind_group_layout,
81            &bgl,
82        );
83
84        self.image_slice.bgl = Some(bgl);
85        self.image_slice.pipeline = Some(crate::resources::builders::build_dual_pipeline(
86            device,
87            &crate::resources::builders::DualPipelineDesc {
88                label: "image_slice_pipeline",
89                layout: &layout,
90                shader: &shader,
91                vertex_entry: "vs_main",
92                fragment_entry: "fs_main",
93                vertex_buffers: &[], // no vertex buffer: generates quad from vertex_index
94                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
95                topology: wgpu::PrimitiveTopology::TriangleList,
96                cull_mode: None,
97                depth_write: false,
98                depth_compare: wgpu::CompareFunction::LessEqual,
99                sample_count: self.sample_count,
100                ldr_format: self.target_format,
101            },
102        ));
103    }
104
105    /// Upload one [`ImageSliceItem`] to the GPU and return draw data.
106    ///
107    /// Creates a uniform buffer describing the slice parameters and a bind group
108    /// referencing the existing uploaded volume texture.  No vertex buffer is needed:
109    /// the shader generates a quad from `vertex_index`.
110    pub(crate) fn upload_image_slice(
111        &mut self,
112        device: &wgpu::Device,
113        queue: &wgpu::Queue,
114        item: &crate::renderer::ImageSliceItem,
115    ) -> Option<crate::resources::ImageSliceGpuData> {
116        // Check volume exists before allocating anything.
117        if item.volume_id.0 >= self.content.volume_textures.len() {
118            return None;
119        }
120
121        #[repr(C)]
122        #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
123        struct ImageSliceUniform {
124            bbox_min: [f32; 3],
125            axis: u32,
126            bbox_max: [f32; 3],
127            offset: f32,
128            scalar_min: f32,
129            scalar_max: f32,
130            opacity: f32,
131            _pad: f32,
132        }
133
134        let axis_u32 = match item.axis {
135            crate::renderer::SliceAxis::X => 0u32,
136            crate::renderer::SliceAxis::Y => 1u32,
137            crate::renderer::SliceAxis::Z => 2u32,
138        };
139
140        let uniform_data = ImageSliceUniform {
141            bbox_min: item.bbox_min,
142            axis: axis_u32,
143            bbox_max: item.bbox_max,
144            offset: item.offset.clamp(0.0, 1.0),
145            scalar_min: item.scalar_range.0,
146            scalar_max: item.scalar_range.1,
147            opacity: item.opacity,
148            _pad: 0.0,
149        };
150
151        let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
152            label: Some("image_slice_uniform_buf"),
153            size: std::mem::size_of::<ImageSliceUniform>() as u64,
154            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
155            mapped_at_creation: false,
156        });
157        queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&uniform_data));
158
159        // Nearest-neighbor sampler for crisp slice sampling.
160        let vol_sampler =
161            crate::resources::builders::clamp_nearest_sampler(device, "image_slice_vol_sampler");
162
163        // Resolve LUT view index before creating any bind group references.
164        let lut_view_idx: Option<usize> = self.content.builtin_colourmap_ids.and_then(|ids| {
165            let preset_id = item
166                .colour_lut
167                .unwrap_or(ids[crate::resources::BuiltinColourmap::Viridis as usize]);
168            if preset_id.0 < self.content.colourmap_views.len() {
169                Some(preset_id.0)
170            } else {
171                None
172            }
173        });
174
175        let bgl = self
176            .image_slice
177            .bgl
178            .as_ref()
179            .expect("ensure_image_slice_pipeline not called");
180
181        // Borrow vol_view and lut_view after all mutable references are resolved.
182        let vol_view = &self
183            .content
184            .volume_textures
185            .get(item.volume_id.0)
186            .expect("volume existence checked above")
187            .1;
188        let lut_view = lut_view_idx
189            .map(|i| &self.content.colourmap_views[i])
190            .unwrap_or(&self.content.fallback_lut_view);
191
192        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
193            label: Some("image_slice_bg"),
194            layout: bgl,
195            entries: &[
196                wgpu::BindGroupEntry {
197                    binding: 0,
198                    resource: uniform_buf.as_entire_binding(),
199                },
200                wgpu::BindGroupEntry {
201                    binding: 1,
202                    resource: wgpu::BindingResource::TextureView(vol_view),
203                },
204                wgpu::BindGroupEntry {
205                    binding: 2,
206                    resource: wgpu::BindingResource::Sampler(&vol_sampler),
207                },
208                wgpu::BindGroupEntry {
209                    binding: 3,
210                    resource: wgpu::BindingResource::TextureView(lut_view),
211                },
212                wgpu::BindGroupEntry {
213                    binding: 4,
214                    resource: wgpu::BindingResource::Sampler(&self.material_sampler),
215                },
216            ],
217        });
218
219        Some(crate::resources::ImageSliceGpuData {
220            bind_group,
221            _uniform_buf: uniform_buf,
222        })
223    }
224
225    // -------------------------------------------------------------------------
226    // Screen-space image overlays
227    // -------------------------------------------------------------------------
228
229    /// Lazily create the screen-space image render pipeline.
230    ///
231    /// No-op if already created. Called from `prepare()` when
232    /// `frame.scene.screen_images` is non-empty.
233    pub(crate) fn ensure_screen_image_pipeline(&mut self, device: &wgpu::Device) {
234        if self.screen_image.pipeline.is_some() {
235            return;
236        }
237
238        let shader = crate::resources::builders::wgsl_module(
239            device,
240            "screen_image_shader",
241            crate::resources::builders::wgsl_source!("screen_image"),
242        );
243
244        // binding 0: ScreenImageUniform, binding 1: texture_2d<f32>, binding 2: sampler.
245        let bgl = crate::resources::builders::uniform_texture_sampler_bgl(
246            device,
247            "screen_image_bgl",
248            wgpu::ShaderStages::VERTEX_FRAGMENT,
249            wgpu::ShaderStages::FRAGMENT,
250        );
251
252        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
253            label: Some("screen_image_layout"),
254            bind_group_layouts: &[&bgl],
255            push_constant_ranges: &[],
256        });
257
258        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
259            label: Some("screen_image_pipeline"),
260            layout: Some(&layout),
261            vertex: wgpu::VertexState {
262                module: &shader,
263                entry_point: Some("vs_main"),
264                buffers: &[],
265                compilation_options: wgpu::PipelineCompilationOptions::default(),
266            },
267            fragment: Some(wgpu::FragmentState {
268                module: &shader,
269                entry_point: Some("fs_main"),
270                targets: &[Some(wgpu::ColorTargetState {
271                    format: self.target_format,
272                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
273                    write_mask: wgpu::ColorWrites::ALL,
274                })],
275                compilation_options: wgpu::PipelineCompilationOptions::default(),
276            }),
277            primitive: wgpu::PrimitiveState {
278                topology: wgpu::PrimitiveTopology::TriangleList,
279                cull_mode: None,
280                ..Default::default()
281            },
282            // Use Always depth compare (never test) so screen images are always on top.
283            // No depth writes. Format must match the depth attachment of the render pass.
284            depth_stencil: Some(wgpu::DepthStencilState {
285                format: wgpu::TextureFormat::Depth24PlusStencil8,
286                depth_write_enabled: false,
287                depth_compare: wgpu::CompareFunction::Always,
288                stencil: wgpu::StencilState::default(),
289                bias: wgpu::DepthBiasState::default(),
290            }),
291            multisample: wgpu::MultisampleState {
292                count: 1,
293                mask: !0,
294                alpha_to_coverage_enabled: false,
295            },
296            multiview: None,
297            cache: None,
298        });
299
300        self.screen_image.bgl = Some(bgl);
301        self.screen_image.pipeline = Some(pipeline);
302    }
303
304    /// Lazily create the depth-composite screen-image render pipeline.
305    ///
306    /// No-op if already created. Called from `prepare()` when any submitted
307    /// `ScreenImageItem` carries per-pixel depth data.
308    pub(crate) fn ensure_screen_image_dc_pipeline(&mut self, device: &wgpu::Device) {
309        if self.screen_image.dc_pipeline.is_some() {
310            return;
311        }
312
313        let shader = crate::resources::builders::wgsl_module(
314            device,
315            "screen_image_dc_shader",
316            crate::resources::builders::wgsl_source!("screen_image_dc"),
317        );
318
319        let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
320            label: Some("screen_image_dc_bgl"),
321            entries: &[
322                // binding 0: ScreenImageUniform
323                wgpu::BindGroupLayoutEntry {
324                    binding: 0,
325                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
326                    ty: wgpu::BindingType::Buffer {
327                        ty: wgpu::BufferBindingType::Uniform,
328                        has_dynamic_offset: false,
329                        min_binding_size: None,
330                    },
331                    count: None,
332                },
333                // binding 1: colour texture_2d<f32>
334                wgpu::BindGroupLayoutEntry {
335                    binding: 1,
336                    visibility: wgpu::ShaderStages::FRAGMENT,
337                    ty: wgpu::BindingType::Texture {
338                        multisampled: false,
339                        view_dimension: wgpu::TextureViewDimension::D2,
340                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
341                    },
342                    count: None,
343                },
344                // binding 2: sampler (filtering, for colour texture)
345                wgpu::BindGroupLayoutEntry {
346                    binding: 2,
347                    visibility: wgpu::ShaderStages::FRAGMENT,
348                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
349                    count: None,
350                },
351                // binding 3: R32Float depth texture (non-filterable, read via textureLoad)
352                wgpu::BindGroupLayoutEntry {
353                    binding: 3,
354                    visibility: wgpu::ShaderStages::FRAGMENT,
355                    ty: wgpu::BindingType::Texture {
356                        multisampled: false,
357                        view_dimension: wgpu::TextureViewDimension::D2,
358                        sample_type: wgpu::TextureSampleType::Float { filterable: false },
359                    },
360                    count: None,
361                },
362            ],
363        });
364
365        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
366            label: Some("screen_image_dc_layout"),
367            bind_group_layouts: &[&bgl],
368            push_constant_ranges: &[],
369        });
370
371        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
372            label: Some("screen_image_dc_pipeline"),
373            layout: Some(&layout),
374            vertex: wgpu::VertexState {
375                module: &shader,
376                entry_point: Some("vs_main"),
377                buffers: &[],
378                compilation_options: wgpu::PipelineCompilationOptions::default(),
379            },
380            fragment: Some(wgpu::FragmentState {
381                module: &shader,
382                entry_point: Some("fs_main"),
383                targets: &[Some(wgpu::ColorTargetState {
384                    format: self.target_format,
385                    blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING),
386                    write_mask: wgpu::ColorWrites::ALL,
387                })],
388                compilation_options: wgpu::PipelineCompilationOptions::default(),
389            }),
390            primitive: wgpu::PrimitiveState {
391                topology: wgpu::PrimitiveTopology::TriangleList,
392                cull_mode: None,
393                ..Default::default()
394            },
395            // Depth test: discard fragments whose image depth exceeds scene depth.
396            // depth_write_enabled: false so the scene depth buffer is not modified.
397            depth_stencil: Some(wgpu::DepthStencilState {
398                format: wgpu::TextureFormat::Depth24PlusStencil8,
399                depth_write_enabled: false,
400                depth_compare: wgpu::CompareFunction::LessEqual,
401                stencil: wgpu::StencilState::default(),
402                bias: wgpu::DepthBiasState::default(),
403            }),
404            multisample: wgpu::MultisampleState {
405                count: 1,
406                mask: !0,
407                alpha_to_coverage_enabled: false,
408            },
409            multiview: None,
410            cache: None,
411        });
412
413        self.screen_image.dc_bgl = Some(bgl);
414        self.screen_image.dc_pipeline = Some(pipeline);
415    }
416
417    /// Upload one [`ScreenImageItem`] to the GPU and return its per-frame GPU data.
418    ///
419    /// Creates a new RGBA8Unorm texture each call : intended for per-frame data.
420    /// The returned [`ScreenImageGpuData`] is valid only for one frame.
421    pub(crate) fn upload_screen_image(
422        &self,
423        device: &wgpu::Device,
424        queue: &wgpu::Queue,
425        item: &crate::ScreenImageItem,
426        viewport_w: f32,
427        viewport_h: f32,
428    ) -> ScreenImageGpuData {
429        use crate::ImageAnchor;
430
431        // Infer the physical texture dimensions from the pixel buffer.
432        // item.width/height are in logical pixels (the visual size); callers may
433        // supply a higher-resolution buffer (e.g. width*ppp x height*ppp) for
434        // crisp HiDPI rendering. The integer scale factor is derived from the
435        // ratio of pixel count to logical area.
436        let logical_area = (item.width * item.height) as usize;
437        let tex_scale = if logical_area > 0 {
438            let ratio = item.pixels.len() / logical_area;
439            (ratio as f32).sqrt().round() as u32
440        } else {
441            1
442        }
443        .max(1);
444        let tex_w = (item.width * tex_scale).max(1);
445        let tex_h = (item.height * tex_scale).max(1);
446
447        // Create texture from pixel data.
448        let texture = device.create_texture(&wgpu::TextureDescriptor {
449            label: Some("screen_image_tex"),
450            size: wgpu::Extent3d {
451                width: tex_w,
452                height: tex_h,
453                depth_or_array_layers: 1,
454            },
455            mip_level_count: 1,
456            sample_count: 1,
457            dimension: wgpu::TextureDimension::D2,
458            format: wgpu::TextureFormat::Rgba8UnormSrgb,
459            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
460            view_formats: &[],
461        });
462
463        if !item.pixels.is_empty() && item.width > 0 && item.height > 0 {
464            let raw: Vec<u8> = item.pixels.iter().flat_map(|p| p.iter().copied()).collect();
465            let needed = (tex_w as usize) * (tex_h as usize) * 4;
466            if raw.len() < needed {
467                tracing::warn!(
468                    target: "viewport_lib::screen_image",
469                    width = item.width,
470                    height = item.height,
471                    pixels_len = item.pixels.len(),
472                    inferred_tex_w = tex_w,
473                    inferred_tex_h = tex_h,
474                    expected_bytes = needed,
475                    actual_bytes = raw.len(),
476                    "ScreenImageItem pixel buffer is smaller than the inferred texture size \
477                     (item.width * item.height does not divide pixels.len() into a square scale \
478                     factor). Skipping upload; the image will render blank. Resize the buffer or \
479                     set item.width / item.height to match the buffer's actual physical resolution."
480                );
481            } else {
482                queue.write_texture(
483                    wgpu::TexelCopyTextureInfo {
484                        texture: &texture,
485                        mip_level: 0,
486                        origin: wgpu::Origin3d::ZERO,
487                        aspect: wgpu::TextureAspect::All,
488                    },
489                    &raw,
490                    wgpu::TexelCopyBufferLayout {
491                        offset: 0,
492                        bytes_per_row: Some(tex_w * 4),
493                        rows_per_image: Some(tex_h),
494                    },
495                    wgpu::Extent3d {
496                        width: tex_w,
497                        height: tex_h,
498                        depth_or_array_layers: 1,
499                    },
500                );
501            }
502        }
503
504        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
505        let sampler =
506            crate::resources::builders::clamp_linear_sampler(device, "screen_image_sampler");
507
508        // Compute NDC extents from anchor, image size, and scale.
509        let img_w_ndc = 2.0 * item.width as f32 * item.scale / viewport_w.max(1.0);
510        let img_h_ndc = 2.0 * item.height as f32 * item.scale / viewport_h.max(1.0);
511
512        let (ndc_min_x, ndc_max_x, ndc_min_y, ndc_max_y) = match item.anchor {
513            ImageAnchor::TopLeft => (-1.0, -1.0 + img_w_ndc, 1.0 - img_h_ndc, 1.0),
514            ImageAnchor::TopRight => (1.0 - img_w_ndc, 1.0, 1.0 - img_h_ndc, 1.0),
515            ImageAnchor::BottomLeft => (-1.0, -1.0 + img_w_ndc, -1.0, -1.0 + img_h_ndc),
516            ImageAnchor::BottomRight => (1.0 - img_w_ndc, 1.0, -1.0, -1.0 + img_h_ndc),
517            _ => (
518                -img_w_ndc * 0.5,
519                img_w_ndc * 0.5,
520                -img_h_ndc * 0.5,
521                img_h_ndc * 0.5,
522            ),
523        };
524
525        // ScreenImageUniform: ndc_min(vec2) + ndc_max(vec2) + alpha(f32) + pad(3xf32) = 32 bytes
526        #[repr(C)]
527        #[derive(bytemuck::Pod, bytemuck::Zeroable, Clone, Copy)]
528        struct ScreenImageUniform {
529            ndc_min: [f32; 2],
530            ndc_max: [f32; 2],
531            alpha: f32,
532            _pad: [f32; 3],
533        }
534
535        let uniform_data = ScreenImageUniform {
536            ndc_min: [ndc_min_x, ndc_min_y],
537            ndc_max: [ndc_max_x, ndc_max_y],
538            alpha: item.alpha,
539            _pad: [0.0; 3],
540        };
541
542        let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
543            label: Some("screen_image_uniform"),
544            size: std::mem::size_of::<ScreenImageUniform>() as u64,
545            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
546            mapped_at_creation: false,
547        });
548        queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&uniform_data));
549
550        let bgl = self
551            .screen_image
552            .bgl
553            .as_ref()
554            .expect("ensure_screen_image_pipeline not called");
555
556        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
557            label: Some("screen_image_bg"),
558            layout: bgl,
559            entries: &[
560                wgpu::BindGroupEntry {
561                    binding: 0,
562                    resource: uniform_buf.as_entire_binding(),
563                },
564                wgpu::BindGroupEntry {
565                    binding: 1,
566                    resource: wgpu::BindingResource::TextureView(&view),
567                },
568                wgpu::BindGroupEntry {
569                    binding: 2,
570                    resource: wgpu::BindingResource::Sampler(&sampler),
571                },
572            ],
573        });
574
575        // If the item carries per-pixel depth data, upload a R32Float depth texture
576        // and create a second bind group for the depth-composite pipeline.
577        let (depth_texture_opt, depth_bind_group_opt) = if let Some(depth_values) = &item.depth {
578            let dc_bgl =
579                self.screen_image.dc_bgl.as_ref().expect(
580                    "ensure_screen_image_dc_pipeline not called before upload_screen_image",
581                );
582
583            let dtex = device.create_texture(&wgpu::TextureDescriptor {
584                label: Some("screen_image_depth_tex"),
585                size: wgpu::Extent3d {
586                    width: item.width.max(1),
587                    height: item.height.max(1),
588                    depth_or_array_layers: 1,
589                },
590                mip_level_count: 1,
591                sample_count: 1,
592                dimension: wgpu::TextureDimension::D2,
593                format: wgpu::TextureFormat::R32Float,
594                usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
595                view_formats: &[],
596            });
597
598            // Upload depth values as raw bytes (each f32 = 4 bytes).
599            let pixel_count = (item.width * item.height) as usize;
600            let safe_depth: Vec<f32> = if depth_values.len() >= pixel_count {
601                depth_values[..pixel_count].to_vec()
602            } else {
603                // Pad with far-plane depth (1.0) if caller supplied too few values.
604                let mut v = depth_values.clone();
605                v.resize(pixel_count, 1.0);
606                v
607            };
608
609            if item.width > 0 && item.height > 0 {
610                queue.write_texture(
611                    wgpu::TexelCopyTextureInfo {
612                        texture: &dtex,
613                        mip_level: 0,
614                        origin: wgpu::Origin3d::ZERO,
615                        aspect: wgpu::TextureAspect::All,
616                    },
617                    bytemuck::cast_slice(&safe_depth),
618                    wgpu::TexelCopyBufferLayout {
619                        offset: 0,
620                        bytes_per_row: Some(item.width * 4),
621                        rows_per_image: Some(item.height),
622                    },
623                    wgpu::Extent3d {
624                        width: item.width,
625                        height: item.height,
626                        depth_or_array_layers: 1,
627                    },
628                );
629            }
630
631            let dview = dtex.create_view(&wgpu::TextureViewDescriptor::default());
632
633            let dc_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
634                label: Some("screen_image_dc_bg"),
635                layout: dc_bgl,
636                entries: &[
637                    wgpu::BindGroupEntry {
638                        binding: 0,
639                        resource: uniform_buf.as_entire_binding(),
640                    },
641                    wgpu::BindGroupEntry {
642                        binding: 1,
643                        resource: wgpu::BindingResource::TextureView(&view),
644                    },
645                    wgpu::BindGroupEntry {
646                        binding: 2,
647                        resource: wgpu::BindingResource::Sampler(&sampler),
648                    },
649                    wgpu::BindGroupEntry {
650                        binding: 3,
651                        resource: wgpu::BindingResource::TextureView(&dview),
652                    },
653                ],
654            });
655
656            (Some(dtex), Some(dc_bg))
657        } else {
658            (None, None)
659        };
660
661        ScreenImageGpuData {
662            _uniform_buf: uniform_buf,
663            _texture: texture,
664            bind_group,
665            _depth_texture: depth_texture_opt,
666            depth_bind_group: depth_bind_group_opt,
667        }
668    }
669
670    /// Upload one [`OverlayImageItem`] to the GPU and return its render data.
671    ///
672    /// Reuses the `screen_image_pipeline` and its bind group layout; the shaders and
673    /// uniform layout are identical. No depth path: `OverlayImageItem` has no depth field.
674    ///
675    /// Caller must have called [`ensure_screen_image_pipeline`] first.
676    pub(crate) fn upload_overlay_image(
677        &self,
678        device: &wgpu::Device,
679        queue: &wgpu::Queue,
680        item: &crate::OverlayImageItem,
681        viewport_w: f32,
682        viewport_h: f32,
683    ) -> ScreenImageGpuData {
684        use crate::ImageAnchor;
685
686        let logical_area = (item.width * item.height) as usize;
687        let tex_scale = if logical_area > 0 {
688            let ratio = item.pixels.len() / logical_area;
689            (ratio as f32).sqrt().round() as u32
690        } else {
691            1
692        }
693        .max(1);
694        let tex_w = (item.width * tex_scale).max(1);
695        let tex_h = (item.height * tex_scale).max(1);
696
697        let texture = device.create_texture(&wgpu::TextureDescriptor {
698            label: Some("overlay_image_tex"),
699            size: wgpu::Extent3d {
700                width: tex_w,
701                height: tex_h,
702                depth_or_array_layers: 1,
703            },
704            mip_level_count: 1,
705            sample_count: 1,
706            dimension: wgpu::TextureDimension::D2,
707            format: wgpu::TextureFormat::Rgba8UnormSrgb,
708            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
709            view_formats: &[],
710        });
711
712        if !item.pixels.is_empty() && item.width > 0 && item.height > 0 {
713            let raw: Vec<u8> = item.pixels.iter().flat_map(|p| p.iter().copied()).collect();
714            let needed = (tex_w as usize) * (tex_h as usize) * 4;
715            if raw.len() < needed {
716                tracing::warn!(
717                    target: "viewport_lib::overlay_image",
718                    width = item.width,
719                    height = item.height,
720                    pixels_len = item.pixels.len(),
721                    inferred_tex_w = tex_w,
722                    inferred_tex_h = tex_h,
723                    expected_bytes = needed,
724                    actual_bytes = raw.len(),
725                    "OverlayImageItem pixel buffer is smaller than the inferred texture size \
726                     (item.width * item.height does not divide pixels.len() into a square scale \
727                     factor). Skipping upload; the image will render blank. Resize the buffer or \
728                     set item.width / item.height to match the buffer's actual physical resolution."
729                );
730            } else {
731                queue.write_texture(
732                    wgpu::TexelCopyTextureInfo {
733                        texture: &texture,
734                        mip_level: 0,
735                        origin: wgpu::Origin3d::ZERO,
736                        aspect: wgpu::TextureAspect::All,
737                    },
738                    &raw,
739                    wgpu::TexelCopyBufferLayout {
740                        offset: 0,
741                        bytes_per_row: Some(tex_w * 4),
742                        rows_per_image: Some(tex_h),
743                    },
744                    wgpu::Extent3d {
745                        width: tex_w,
746                        height: tex_h,
747                        depth_or_array_layers: 1,
748                    },
749                );
750            }
751        }
752
753        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
754        let sampler =
755            crate::resources::builders::clamp_linear_sampler(device, "overlay_image_sampler");
756
757        let img_w_ndc = 2.0 * item.width as f32 * item.scale / viewport_w.max(1.0);
758        let img_h_ndc = 2.0 * item.height as f32 * item.scale / viewport_h.max(1.0);
759
760        let (ndc_min_x, ndc_max_x, ndc_min_y, ndc_max_y) = match item.anchor {
761            ImageAnchor::TopLeft => (-1.0, -1.0 + img_w_ndc, 1.0 - img_h_ndc, 1.0),
762            ImageAnchor::TopRight => (1.0 - img_w_ndc, 1.0, 1.0 - img_h_ndc, 1.0),
763            ImageAnchor::BottomLeft => (-1.0, -1.0 + img_w_ndc, -1.0, -1.0 + img_h_ndc),
764            ImageAnchor::BottomRight => (1.0 - img_w_ndc, 1.0, -1.0, -1.0 + img_h_ndc),
765            _ => (
766                -img_w_ndc * 0.5,
767                img_w_ndc * 0.5,
768                -img_h_ndc * 0.5,
769                img_h_ndc * 0.5,
770            ),
771        };
772
773        #[repr(C)]
774        #[derive(bytemuck::Pod, bytemuck::Zeroable, Clone, Copy)]
775        struct ScreenImageUniform {
776            ndc_min: [f32; 2],
777            ndc_max: [f32; 2],
778            alpha: f32,
779            _pad: [f32; 3],
780        }
781
782        let uniform_data = ScreenImageUniform {
783            ndc_min: [ndc_min_x, ndc_min_y],
784            ndc_max: [ndc_max_x, ndc_max_y],
785            alpha: item.alpha,
786            _pad: [0.0; 3],
787        };
788
789        let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
790            label: Some("overlay_image_uniform"),
791            size: std::mem::size_of::<ScreenImageUniform>() as u64,
792            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
793            mapped_at_creation: false,
794        });
795        queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&uniform_data));
796
797        let bgl = self
798            .screen_image
799            .bgl
800            .as_ref()
801            .expect("ensure_screen_image_pipeline not called before upload_overlay_image");
802
803        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
804            label: Some("overlay_image_bg"),
805            layout: bgl,
806            entries: &[
807                wgpu::BindGroupEntry {
808                    binding: 0,
809                    resource: uniform_buf.as_entire_binding(),
810                },
811                wgpu::BindGroupEntry {
812                    binding: 1,
813                    resource: wgpu::BindingResource::TextureView(&view),
814                },
815                wgpu::BindGroupEntry {
816                    binding: 2,
817                    resource: wgpu::BindingResource::Sampler(&sampler),
818                },
819            ],
820        });
821
822        ScreenImageGpuData {
823            _uniform_buf: uniform_buf,
824            _texture: texture,
825            bind_group,
826            _depth_texture: None,
827            depth_bind_group: None,
828        }
829    }
830
831    // -------------------------------------------------------------------------
832    // Volume Surface Slice representation
833    // -------------------------------------------------------------------------
834
835    /// Lazily create the volume surface slice render pipeline.
836    ///
837    /// No-op if already created. Called from `prepare()` when
838    /// `frame.scene.volume_surface_slices` is non-empty.
839    pub(crate) fn ensure_volume_surface_slice_pipeline(&mut self, device: &wgpu::Device) {
840        if self.volume.surface_slice_pipeline.is_some() {
841            return;
842        }
843
844        let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
845            label: Some("volume_surface_slice_bgl"),
846            entries: &[
847                // binding 0: VolumeSurfaceSliceUniform
848                wgpu::BindGroupLayoutEntry {
849                    binding: 0,
850                    visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
851                    ty: wgpu::BindingType::Buffer {
852                        ty: wgpu::BufferBindingType::Uniform,
853                        has_dynamic_offset: false,
854                        min_binding_size: None,
855                    },
856                    count: None,
857                },
858                // binding 1: texture_3d<f32> (R32Float, non-filterable)
859                wgpu::BindGroupLayoutEntry {
860                    binding: 1,
861                    visibility: wgpu::ShaderStages::FRAGMENT,
862                    ty: wgpu::BindingType::Texture {
863                        multisampled: false,
864                        view_dimension: wgpu::TextureViewDimension::D3,
865                        sample_type: wgpu::TextureSampleType::Float { filterable: false },
866                    },
867                    count: None,
868                },
869                // binding 2: vol_sampler (non-filtering nearest)
870                wgpu::BindGroupLayoutEntry {
871                    binding: 2,
872                    visibility: wgpu::ShaderStages::FRAGMENT,
873                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
874                    count: None,
875                },
876                // binding 3: lut_tex (colourmap texture_2d)
877                wgpu::BindGroupLayoutEntry {
878                    binding: 3,
879                    visibility: wgpu::ShaderStages::FRAGMENT,
880                    ty: wgpu::BindingType::Texture {
881                        multisampled: false,
882                        view_dimension: wgpu::TextureViewDimension::D2,
883                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
884                    },
885                    count: None,
886                },
887                // binding 4: lut_sampler (linear)
888                wgpu::BindGroupLayoutEntry {
889                    binding: 4,
890                    visibility: wgpu::ShaderStages::FRAGMENT,
891                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
892                    count: None,
893                },
894            ],
895        });
896
897        let shader = crate::resources::builders::wgsl_module(
898            device,
899            "volume_surface_slice_shader",
900            crate::resources::builders::wgsl_source!("volume_surface_slice"),
901        );
902
903        let layout = crate::resources::builders::standard_scene_layout(
904            device,
905            "volume_surface_slice_layout",
906            &self.camera_bind_group_layout,
907            &bgl,
908        );
909
910        self.volume.surface_slice_bgl = Some(bgl);
911        self.volume.surface_slice_pipeline = Some(crate::resources::builders::build_dual_pipeline(
912            device,
913            &crate::resources::builders::DualPipelineDesc {
914                label: "volume_surface_slice_pipeline",
915                layout: &layout,
916                shader: &shader,
917                vertex_entry: "vs_main",
918                fragment_entry: "fs_main",
919                vertex_buffers: &[Vertex::buffer_layout()],
920                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
921                topology: wgpu::PrimitiveTopology::TriangleList,
922                cull_mode: None,
923                depth_write: true,
924                depth_compare: wgpu::CompareFunction::LessEqual,
925                sample_count: self.sample_count,
926                ldr_format: self.target_format,
927            },
928        ));
929    }
930
931    /// Upload one [`VolumeSurfaceSliceItem`] and return per-frame GPU data.
932    ///
933    /// Creates a uniform buffer and a bind group pointing at the existing uploaded
934    /// volume texture and colourmap LUT. The mesh vertex/index buffers are referenced
935    /// by `MeshId` and looked up from the mesh store at draw time.
936    pub(crate) fn upload_volume_surface_slice(
937        &mut self,
938        device: &wgpu::Device,
939        queue: &wgpu::Queue,
940        item: &crate::renderer::VolumeSurfaceSliceItem,
941    ) -> Option<crate::resources::VolumeSurfaceSliceGpuData> {
942        if item.volume_id.0 >= self.content.volume_textures.len() {
943            return None;
944        }
945        // Verify the mesh exists.
946        if self.mesh_store.get(item.mesh_id).is_none() {
947            return None;
948        }
949
950        #[repr(C)]
951        #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
952        struct VolumeSurfaceSliceUniform {
953            model: [[f32; 4]; 4],
954            bbox_min: [f32; 3],
955            scalar_min: f32,
956            bbox_max: [f32; 3],
957            scalar_max: f32,
958            opacity: f32,
959            _pad: [f32; 3],
960        }
961
962        let uniform_data = VolumeSurfaceSliceUniform {
963            model: item.model,
964            bbox_min: item.bbox_min,
965            scalar_min: item.scalar_range.0,
966            bbox_max: item.bbox_max,
967            scalar_max: item.scalar_range.1,
968            // ItemSettings.opacity multiplies into the type's own opacity field
969            // so consumers can drive transparency through the standard per-item
970            // settings without abandoning the existing field.
971            opacity: item.opacity * item.settings.opacity,
972            _pad: [0.0; 3],
973        };
974
975        let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
976            label: Some("volume_surface_slice_uniform"),
977            size: std::mem::size_of::<VolumeSurfaceSliceUniform>() as u64,
978            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
979            mapped_at_creation: false,
980        });
981        queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&uniform_data));
982
983        let vol_sampler = crate::resources::builders::clamp_nearest_sampler(
984            device,
985            "volume_surface_slice_vol_sampler",
986        );
987
988        let lut_view_idx: Option<usize> = self.content.builtin_colourmap_ids.and_then(|ids| {
989            let preset_id = item
990                .colour_lut
991                .unwrap_or(ids[crate::resources::BuiltinColourmap::Viridis as usize]);
992            if preset_id.0 < self.content.colourmap_views.len() {
993                Some(preset_id.0)
994            } else {
995                None
996            }
997        });
998
999        let bgl = self
1000            .volume
1001            .surface_slice_bgl
1002            .as_ref()
1003            .expect("ensure_volume_surface_slice_pipeline not called");
1004
1005        let vol_view = &self
1006            .content
1007            .volume_textures
1008            .get(item.volume_id.0)
1009            .expect("volume existence checked above")
1010            .1;
1011        let lut_view = lut_view_idx
1012            .map(|i| &self.content.colourmap_views[i])
1013            .unwrap_or(&self.content.fallback_lut_view);
1014
1015        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
1016            label: Some("volume_surface_slice_bg"),
1017            layout: bgl,
1018            entries: &[
1019                wgpu::BindGroupEntry {
1020                    binding: 0,
1021                    resource: uniform_buf.as_entire_binding(),
1022                },
1023                wgpu::BindGroupEntry {
1024                    binding: 1,
1025                    resource: wgpu::BindingResource::TextureView(vol_view),
1026                },
1027                wgpu::BindGroupEntry {
1028                    binding: 2,
1029                    resource: wgpu::BindingResource::Sampler(&vol_sampler),
1030                },
1031                wgpu::BindGroupEntry {
1032                    binding: 3,
1033                    resource: wgpu::BindingResource::TextureView(lut_view),
1034                },
1035                wgpu::BindGroupEntry {
1036                    binding: 4,
1037                    resource: wgpu::BindingResource::Sampler(&self.material_sampler),
1038                },
1039            ],
1040        });
1041
1042        Some(crate::resources::VolumeSurfaceSliceGpuData {
1043            bind_group,
1044            _uniform_buf: uniform_buf,
1045            mesh_id: item.mesh_id,
1046        })
1047    }
1048
1049    /// Lazily create the screen-rect outline mask pipeline.
1050    ///
1051    /// Renders an NDC-space quad into the R8Unorm outline mask. Uses a single
1052    /// bind group (group 0) with one uniform binding (NdcRectUniform, 16 bytes).
1053    /// No camera bind group needed. No-op if already created.
1054    pub(crate) fn ensure_screen_rect_outline_mask_pipeline(&mut self, device: &wgpu::Device) {
1055        if self.screen_image.rect_outline_mask_pipeline.is_some() {
1056            return;
1057        }
1058
1059        let bgl = crate::resources::builders::uniform_bgl(
1060            device,
1061            "screen_rect_outline_bgl",
1062            wgpu::ShaderStages::VERTEX,
1063        );
1064
1065        let shader = crate::resources::builders::wgsl_module(
1066            device,
1067            "screen_rect_outline_mask_shader",
1068            crate::resources::builders::wgsl_source!("outline_mask_ndc"),
1069        );
1070
1071        let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
1072            label: Some("screen_rect_outline_mask_pipeline_layout"),
1073            bind_group_layouts: &[&bgl],
1074            push_constant_ranges: &[],
1075        });
1076
1077        let pipeline = crate::resources::builders::build_outline_mask_pipeline(
1078            device,
1079            "screen_rect_outline_mask_pipeline",
1080            &layout,
1081            &shader,
1082            wgpu::TextureFormat::R8Unorm,
1083            &[],
1084            None,
1085            false,
1086            wgpu::CompareFunction::Always,
1087        );
1088
1089        self.screen_image.rect_outline_bgl = Some(bgl);
1090        self.screen_image.rect_outline_mask_pipeline = Some(pipeline);
1091    }
1092}
1093
1094/// Per-frame GPU data for one screen-space image overlay, created in `prepare()`.
1095pub struct ScreenImageGpuData {
1096    /// Uniform buffer: `ScreenImageUniform` (32 bytes) with NDC extents and alpha.
1097    pub(crate) _uniform_buf: wgpu::Buffer,
1098    /// Uploaded RGBA8 texture for this image (recreated each frame).
1099    pub(crate) _texture: wgpu::Texture,
1100    /// Bind group (group 0): uniform + colour texture + sampler.
1101    /// Used by the regular pipeline (no depth test).
1102    pub(crate) bind_group: wgpu::BindGroup,
1103    /// Uploaded R32Float depth texture. `None` when the item has no depth data.
1104    pub(crate) _depth_texture: Option<wgpu::Texture>,
1105    /// Bind group for the depth-composite pipeline (group 0: uniform + colour + sampler + depth).
1106    /// `Some` only when the item carries per-pixel depth data.
1107    pub(crate) depth_bind_group: Option<wgpu::BindGroup>,
1108}