Skip to main content

viewport_lib/resources/volume/
volumes.rs

1use crate::resources::*;
2
3/// Direct volume rendering pipelines, layouts, the cached unit cube geometry,
4/// and the default opacity LUT. All lazily built; the uploaded 3D volume
5/// textures live in a separate flat store.
6#[derive(Default)]
7pub(crate) struct VolumeResources {
8    /// Volume render pipeline. None until first volume is submitted.
9    pub(crate) pipeline: Option<DualPipeline>,
10    /// Bind group layout for volume uniforms (group 1).
11    pub(crate) bgl: Option<wgpu::BindGroupLayout>,
12    /// Cached unit cube vertex buffer for bounding-box rasterization.
13    pub(crate) cube_vb: Option<wgpu::Buffer>,
14    /// Cached unit cube index buffer.
15    pub(crate) cube_ib: Option<wgpu::Buffer>,
16    /// Default linear ramp opacity LUT texture (256x1, R8Unorm).
17    pub(crate) default_opacity_lut: Option<wgpu::Texture>,
18    pub(crate) default_opacity_lut_view: Option<wgpu::TextureView>,
19    /// Volume surface slice render pipeline. None until first slice item.
20    pub(crate) surface_slice_pipeline: Option<DualPipeline>,
21    /// Bind group layout for volume surface slice uniforms (group 1).
22    pub(crate) surface_slice_bgl: Option<wgpu::BindGroupLayout>,
23    /// Mask-write pipeline for volume AABB cubes. None until first selected volume.
24    pub(crate) outline_mask_pipeline: Option<wgpu::RenderPipeline>,
25}
26
27impl DeviceResources {
28    /// Upload a 3D scalar field to the GPU as an `R32Float` 3D texture.
29    ///
30    /// `data` must be a flat array of `dims[0] * dims[1] * dims[2]` scalars in
31    /// x-fastest order (index = x + y*nx + z*nx*ny).
32    ///
33    /// Returns a [`VolumeId`](crate::resources::VolumeId) that can be stored in [`VolumeItem::volume_id`](crate::renderer::VolumeItem::volume_id).
34    pub fn upload_volume(
35        &mut self,
36        device: &wgpu::Device,
37        queue: &wgpu::Queue,
38        data: &[f32],
39        dims: [u32; 3],
40    ) -> VolumeId {
41        let expected = (dims[0] as usize) * (dims[1] as usize) * (dims[2] as usize);
42        assert_eq!(
43            data.len(),
44            expected,
45            "volume data length {} does not match dims {:?} (expected {})",
46            data.len(),
47            dims,
48            expected
49        );
50
51        let texture = device.create_texture(&wgpu::TextureDescriptor {
52            label: Some("volume_3d_texture"),
53            size: wgpu::Extent3d {
54                width: dims[0],
55                height: dims[1],
56                depth_or_array_layers: dims[2],
57            },
58            mip_level_count: 1,
59            sample_count: 1,
60            dimension: wgpu::TextureDimension::D3,
61            format: wgpu::TextureFormat::R32Float,
62            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
63            view_formats: &[],
64        });
65
66        let bytes: &[u8] = bytemuck::cast_slice(data);
67        queue.write_texture(
68            wgpu::TexelCopyTextureInfo {
69                texture: &texture,
70                mip_level: 0,
71                origin: wgpu::Origin3d::ZERO,
72                aspect: wgpu::TextureAspect::All,
73            },
74            bytes,
75            wgpu::TexelCopyBufferLayout {
76                offset: 0,
77                bytes_per_row: Some(dims[0] * 4),
78                rows_per_image: Some(dims[1]),
79            },
80            wgpu::Extent3d {
81                width: dims[0],
82                height: dims[1],
83                depth_or_array_layers: dims[2],
84            },
85        );
86
87        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
88        VolumeId(self.content.volume_textures.push((texture, view)))
89    }
90
91    /// Start an asynchronous volume upload.
92    ///
93    /// Returns a [`JobId`](crate::resources::JobId) immediately. The 3D
94    /// texture creation and `queue.write_texture` run on a worker thread on
95    /// cloned `Device` and `Queue` handles; once the job reports
96    /// `UploadStatus::Ready`, call
97    /// [`upload_result_volume`](Self::upload_result_volume) to take the
98    /// resulting [`VolumeId`](crate::resources::VolumeId).
99    ///
100    /// Ownership of `data` transfers into the worker.
101    ///
102    /// # Errors
103    ///
104    /// Returns [`ViewportError::VolumeDataLengthMismatch`](crate::error::ViewportError::VolumeDataLengthMismatch)
105    /// if `data.len() != dims[0] * dims[1] * dims[2]` before any job is
106    /// submitted.
107    pub fn begin_upload_volume(
108        &mut self,
109        device: &wgpu::Device,
110        queue: &wgpu::Queue,
111        data: Vec<f32>,
112        dims: [u32; 3],
113    ) -> crate::error::ViewportResult<crate::resources::JobId> {
114        let expected = (dims[0] as usize) * (dims[1] as usize) * (dims[2] as usize);
115        if data.len() != expected {
116            return Err(crate::error::ViewportError::VolumeDataLengthMismatch {
117                actual: data.len(),
118                expected,
119                dims,
120            });
121        }
122
123        let slot = crate::resources::ResultSlot::<VolumeId>::new();
124        let slot_for_apply = slot.clone();
125        let device_for_worker = device.clone();
126        let queue_for_worker = queue.clone();
127
128        let id = {
129            let mut runner = self.jobs.lock().expect("upload job runner poisoned");
130            runner.submit_cpu(move |progress| {
131                progress.set(0.1);
132                let texture = device_for_worker.create_texture(&wgpu::TextureDescriptor {
133                    label: Some("volume_3d_texture"),
134                    size: wgpu::Extent3d {
135                        width: dims[0],
136                        height: dims[1],
137                        depth_or_array_layers: dims[2],
138                    },
139                    mip_level_count: 1,
140                    sample_count: 1,
141                    dimension: wgpu::TextureDimension::D3,
142                    format: wgpu::TextureFormat::R32Float,
143                    usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
144                    view_formats: &[],
145                });
146                let bytes: &[u8] = bytemuck::cast_slice(&data);
147                queue_for_worker.write_texture(
148                    wgpu::TexelCopyTextureInfo {
149                        texture: &texture,
150                        mip_level: 0,
151                        origin: wgpu::Origin3d::ZERO,
152                        aspect: wgpu::TextureAspect::All,
153                    },
154                    bytes,
155                    wgpu::TexelCopyBufferLayout {
156                        offset: 0,
157                        bytes_per_row: Some(dims[0] * 4),
158                        rows_per_image: Some(dims[1]),
159                    },
160                    wgpu::Extent3d {
161                        width: dims[0],
162                        height: dims[1],
163                        depth_or_array_layers: dims[2],
164                    },
165                );
166                let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
167                progress.set(0.95);
168                Ok(crate::resources::upload_jobs::JobProduct::with_apply(
169                    Box::new(move |resources: &mut DeviceResources| {
170                        let id = VolumeId(resources.content.volume_textures.push((texture, view)));
171                        slot_for_apply.set(id);
172                    }),
173                ))
174            })
175        };
176
177        self.job_results
178            .volume
179            .lock()
180            .expect("volume result map poisoned")
181            .insert(id, slot);
182        Ok(id)
183    }
184
185    /// Take the [`VolumeId`](crate::resources::VolumeId) produced by a
186    /// completed [`begin_upload_volume`](Self::begin_upload_volume) job.
187    ///
188    /// Returns `JobNotReady` while the upload is still in flight, and
189    /// `JobResultMissing` for ids that have already been taken, were
190    /// issued by a different upload type, or never existed.
191    pub fn upload_result_volume(
192        &mut self,
193        id: crate::resources::JobId,
194    ) -> crate::error::ViewportResult<VolumeId> {
195        let mut map = self
196            .job_results
197            .volume
198            .lock()
199            .expect("volume result map poisoned");
200        let slot = match map.get(&id) {
201            Some(s) => s.clone(),
202            None => {
203                return Err(crate::error::ViewportError::JobResultMissing {
204                    reason: "unknown id or wrong upload type",
205                });
206            }
207        };
208        match slot.take() {
209            Some(vid) => {
210                map.remove(&id);
211                Ok(vid)
212            }
213            None => Err(crate::error::ViewportError::JobNotReady),
214        }
215    }
216
217    /// Create the volume render pipeline and bind group layout (lazy init).
218    pub(crate) fn ensure_volume_pipeline(&mut self, device: &wgpu::Device) {
219        if self.volume.pipeline.is_some() {
220            return;
221        }
222
223        let bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
224            label: Some("volume_bgl"),
225            entries: &[
226                wgpu::BindGroupLayoutEntry {
227                    binding: 0,
228                    visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
229                    ty: wgpu::BindingType::Buffer {
230                        ty: wgpu::BufferBindingType::Uniform,
231                        has_dynamic_offset: false,
232                        min_binding_size: None,
233                    },
234                    count: None,
235                },
236                wgpu::BindGroupLayoutEntry {
237                    binding: 1,
238                    visibility: wgpu::ShaderStages::FRAGMENT,
239                    ty: wgpu::BindingType::Texture {
240                        sample_type: wgpu::TextureSampleType::Float { filterable: false },
241                        view_dimension: wgpu::TextureViewDimension::D3,
242                        multisampled: false,
243                    },
244                    count: None,
245                },
246                wgpu::BindGroupLayoutEntry {
247                    binding: 2,
248                    visibility: wgpu::ShaderStages::FRAGMENT,
249                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::NonFiltering),
250                    count: None,
251                },
252                wgpu::BindGroupLayoutEntry {
253                    binding: 3,
254                    visibility: wgpu::ShaderStages::FRAGMENT,
255                    ty: wgpu::BindingType::Texture {
256                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
257                        view_dimension: wgpu::TextureViewDimension::D2,
258                        multisampled: false,
259                    },
260                    count: None,
261                },
262                wgpu::BindGroupLayoutEntry {
263                    binding: 4,
264                    visibility: wgpu::ShaderStages::FRAGMENT,
265                    ty: wgpu::BindingType::Texture {
266                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
267                        view_dimension: wgpu::TextureViewDimension::D2,
268                        multisampled: false,
269                    },
270                    count: None,
271                },
272                wgpu::BindGroupLayoutEntry {
273                    binding: 5,
274                    visibility: wgpu::ShaderStages::FRAGMENT,
275                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
276                    count: None,
277                },
278            ],
279        });
280
281        let shader_src = include_str!(concat!(env!("OUT_DIR"), "/volume.wgsl"));
282        let shader = crate::resources::builders::wgsl_module(device, "volume_shader", shader_src);
283
284        let pipeline_layout = crate::resources::builders::standard_scene_layout(
285            device,
286            "volume_pipeline_layout",
287            &self.camera_bind_group_layout,
288            &bgl,
289        );
290
291        let vol_vert_layout = wgpu::VertexBufferLayout {
292            array_stride: 12,
293            step_mode: wgpu::VertexStepMode::Vertex,
294            attributes: &[wgpu::VertexAttribute {
295                format: wgpu::VertexFormat::Float32x3,
296                offset: 0,
297                shader_location: 0,
298            }],
299        };
300        self.volume.pipeline = Some(crate::resources::builders::build_dual_pipeline(
301            device,
302            &crate::resources::builders::DualPipelineDesc {
303                label: "volume_pipeline",
304                layout: &pipeline_layout,
305                shader: &shader,
306                vertex_entry: "vs_main",
307                fragment_entry: "fs_main",
308                vertex_buffers: &[vol_vert_layout.clone()],
309                blend: Some(wgpu::BlendState {
310                    color: wgpu::BlendComponent {
311                        src_factor: wgpu::BlendFactor::SrcAlpha,
312                        dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
313                        operation: wgpu::BlendOperation::Add,
314                    },
315                    alpha: wgpu::BlendComponent {
316                        src_factor: wgpu::BlendFactor::One,
317                        dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha,
318                        operation: wgpu::BlendOperation::Add,
319                    },
320                }),
321                topology: wgpu::PrimitiveTopology::TriangleList,
322                cull_mode: None,
323                depth_write: false,
324                depth_compare: wgpu::CompareFunction::Less,
325                sample_count: self.sample_count,
326                ldr_format: self.target_format,
327            },
328        ));
329        self.volume.bgl = Some(bgl);
330    }
331
332    /// Ensure the volume outline mask pipeline exists. This pipeline ray-marches the
333    /// volume in the R8 mask texture so the outline hugs the actual volume silhouette
334    /// rather than the AABB. Requires `ensure_volume_pipeline` to have been called
335    /// first (needs `volume_bgl`).
336    pub(crate) fn ensure_volume_outline_mask_pipeline(&mut self, device: &wgpu::Device) {
337        if self.volume.outline_mask_pipeline.is_some() {
338            return;
339        }
340        let bgl = self.volume.bgl.as_ref().expect(
341            "ensure_volume_pipeline must be called before ensure_volume_outline_mask_pipeline",
342        );
343
344        let shader = crate::resources::builders::wgsl_module(
345            device,
346            "volume_outline_mask_shader",
347            crate::resources::builders::wgsl_source!("volume_outline_mask"),
348        );
349
350        let layout = crate::resources::builders::standard_scene_layout(
351            device,
352            "volume_outline_mask_pipeline_layout",
353            &self.camera_bind_group_layout,
354            bgl,
355        );
356
357        let vert_attrs = [wgpu::VertexAttribute {
358            offset: 0,
359            shader_location: 0,
360            format: wgpu::VertexFormat::Float32x3,
361        }];
362        let vert_layout = wgpu::VertexBufferLayout {
363            array_stride: 12,
364            step_mode: wgpu::VertexStepMode::Vertex,
365            attributes: &vert_attrs,
366        };
367
368        self.volume.outline_mask_pipeline =
369            Some(crate::resources::builders::build_outline_mask_pipeline(
370                device,
371                "volume_outline_mask_pipeline",
372                &layout,
373                &shader,
374                wgpu::TextureFormat::R8Unorm,
375                &[vert_layout],
376                None,
377                true,
378                wgpu::CompareFunction::Less,
379            ));
380    }
381
382    /// Ensure the unit cube vertex + index buffers for volume bounding box proxy exist.
383    pub(crate) fn ensure_volume_cube(&mut self, device: &wgpu::Device) {
384        if self.volume.cube_vb.is_some() {
385            return;
386        }
387
388        #[rustfmt::skip]
389        let vertices: [[f32; 3]; 8] = [
390            [0.0, 0.0, 0.0],
391            [1.0, 0.0, 0.0],
392            [1.0, 1.0, 0.0],
393            [0.0, 1.0, 0.0],
394            [0.0, 0.0, 1.0],
395            [1.0, 0.0, 1.0],
396            [1.0, 1.0, 1.0],
397            [0.0, 1.0, 1.0],
398        ];
399
400        #[rustfmt::skip]
401        let indices: [u32; 36] = [
402            0, 2, 1,  0, 3, 2,
403            4, 5, 6,  4, 6, 7,
404            0, 4, 7,  0, 7, 3,
405            1, 2, 6,  1, 6, 5,
406            0, 1, 5,  0, 5, 4,
407            3, 7, 6,  3, 6, 2,
408        ];
409
410        let vbuf = device.create_buffer(&wgpu::BufferDescriptor {
411            label: Some("volume_cube_vb"),
412            size: std::mem::size_of_val(&vertices) as u64,
413            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
414            mapped_at_creation: true,
415        });
416        {
417            let mut view = vbuf.slice(..).get_mapped_range_mut();
418            view.copy_from_slice(bytemuck::cast_slice(&vertices));
419        }
420        vbuf.unmap();
421
422        let ibuf = device.create_buffer(&wgpu::BufferDescriptor {
423            label: Some("volume_cube_ib"),
424            size: std::mem::size_of_val(&indices) as u64,
425            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
426            mapped_at_creation: true,
427        });
428        {
429            let mut view = ibuf.slice(..).get_mapped_range_mut();
430            view.copy_from_slice(bytemuck::cast_slice(&indices));
431        }
432        ibuf.unmap();
433
434        self.volume.cube_vb = Some(vbuf);
435        self.volume.cube_ib = Some(ibuf);
436    }
437
438    /// Ensure the default linear ramp opacity LUT exists.
439    fn ensure_default_opacity_lut(&mut self, device: &wgpu::Device, queue: &wgpu::Queue) {
440        if self.volume.default_opacity_lut.is_some() {
441            return;
442        }
443
444        let mut data = [0u8; 256];
445        for (i, v) in data.iter_mut().enumerate() {
446            *v = i as u8;
447        }
448
449        let texture = device.create_texture(&wgpu::TextureDescriptor {
450            label: Some("volume_default_opacity_lut"),
451            size: wgpu::Extent3d {
452                width: 256,
453                height: 1,
454                depth_or_array_layers: 1,
455            },
456            mip_level_count: 1,
457            sample_count: 1,
458            dimension: wgpu::TextureDimension::D2,
459            format: wgpu::TextureFormat::R8Unorm,
460            usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
461            view_formats: &[],
462        });
463
464        queue.write_texture(
465            wgpu::TexelCopyTextureInfo {
466                texture: &texture,
467                mip_level: 0,
468                origin: wgpu::Origin3d::ZERO,
469                aspect: wgpu::TextureAspect::All,
470            },
471            &data,
472            wgpu::TexelCopyBufferLayout {
473                offset: 0,
474                bytes_per_row: Some(256),
475                rows_per_image: Some(1),
476            },
477            wgpu::Extent3d {
478                width: 256,
479                height: 1,
480                depth_or_array_layers: 1,
481            },
482        );
483
484        let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
485        self.volume.default_opacity_lut = Some(texture);
486        self.volume.default_opacity_lut_view = Some(view);
487    }
488
489    /// Prepare per-frame GPU data for a single volume item.
490    pub(crate) fn upload_volume_frame(
491        &mut self,
492        device: &wgpu::Device,
493        queue: &wgpu::Queue,
494        item: &crate::renderer::VolumeItem,
495        clip_objects: &[crate::renderer::ClipObject],
496        // Multiplier applied to the computed step size (1.0 = normal, >1.0 = coarser/faster).
497        step_scale_multiplier: f32,
498    ) -> VolumeGpuData {
499        self.ensure_volume_cube(device);
500        self.ensure_default_opacity_lut(device, queue);
501
502        let vol_id = item.volume_id.0;
503        let dims = {
504            let uploaded = self.content.volume_textures.len();
505            let (tex, _) = self.content.volume_textures.get(vol_id).unwrap_or_else(|| {
506                panic!("invalid VolumeId: {vol_id} (only {uploaded} volumes uploaded)")
507            });
508            let size = tex.size();
509            [size.width, size.height, size.depth_or_array_layers]
510        };
511
512        let item_model = glam::Mat4::from_cols_array_2d(&item.model);
513        let bbox_min = glam::Vec3::from(item.bbox_min);
514        let bbox_max = glam::Vec3::from(item.bbox_max);
515        let extent = bbox_max - bbox_min;
516        let bbox_model = glam::Mat4::from_translation(bbox_min) * glam::Mat4::from_scale(extent);
517        let model = item_model * bbox_model;
518        let inv_model = model.inverse();
519
520        let max_dim = dims[0].max(dims[1]).max(dims[2]) as f32;
521        let step_size = (item.step_scale * step_scale_multiplier) / max_dim.max(1.0);
522
523        let mut clip_plane_data = [[0.0f32; 4]; 6];
524        let mut num_clip = 0u32;
525        for obj in clip_objects.iter().filter(|o| o.enabled) {
526            if num_clip >= 6 {
527                break;
528            }
529            if let crate::renderer::ClipShape::Plane {
530                normal, distance, ..
531            } = obj.shape
532            {
533                clip_plane_data[num_clip as usize] = [normal[0], normal[1], normal[2], distance];
534                num_clip += 1;
535            }
536        }
537
538        let mut uniform_data = [0u8; 304];
539        {
540            let mut offset = 0usize;
541            let model_arr = model.to_cols_array();
542            uniform_data[offset..offset + 64].copy_from_slice(bytemuck::bytes_of(&model_arr));
543            offset += 64;
544            let inv_model_arr = inv_model.to_cols_array();
545            uniform_data[offset..offset + 64].copy_from_slice(bytemuck::bytes_of(&inv_model_arr));
546            offset += 64;
547            uniform_data[offset..offset + 12].copy_from_slice(bytemuck::bytes_of(&item.bbox_min));
548            offset += 12;
549            uniform_data[offset..offset + 4].copy_from_slice(bytemuck::bytes_of(&step_size));
550            offset += 4;
551            uniform_data[offset..offset + 12].copy_from_slice(bytemuck::bytes_of(&item.bbox_max));
552            offset += 12;
553            uniform_data[offset..offset + 4]
554                .copy_from_slice(bytemuck::bytes_of(&item.opacity_scale));
555            offset += 4;
556            uniform_data[offset..offset + 4]
557                .copy_from_slice(bytemuck::bytes_of(&item.scalar_range.0));
558            offset += 4;
559            uniform_data[offset..offset + 4]
560                .copy_from_slice(bytemuck::bytes_of(&item.scalar_range.1));
561            offset += 4;
562            uniform_data[offset..offset + 4]
563                .copy_from_slice(bytemuck::bytes_of(&item.threshold_min));
564            offset += 4;
565            uniform_data[offset..offset + 4]
566                .copy_from_slice(bytemuck::bytes_of(&item.threshold_max));
567            offset += 4;
568            // ItemSettings.unlit forces gradient shading off regardless of the
569            // per-item enable_shading toggle. The volume ray-marcher's only
570            // lighting path is gradient Phong, gated by VolumeUniform.enable_shading;
571            // ORing unlit here gives consumers a uniform way to disable lighting
572            // across all item types.
573            let shading_u32: u32 = if item.enable_shading && !item.settings.unlit {
574                1
575            } else {
576                0
577            };
578            uniform_data[offset..offset + 4].copy_from_slice(bytemuck::bytes_of(&shading_u32));
579            offset += 4;
580            uniform_data[offset..offset + 4].copy_from_slice(bytemuck::bytes_of(&num_clip));
581            offset += 4;
582            let use_nan_colour_u32: u32 = if item.nan_colour.is_some() { 1 } else { 0 };
583            uniform_data[offset..offset + 4]
584                .copy_from_slice(bytemuck::bytes_of(&use_nan_colour_u32));
585            offset += 4;
586            offset += 4;
587            let nan_colour = item.nan_colour.unwrap_or([0.0f32; 4]);
588            uniform_data[offset..offset + 16].copy_from_slice(bytemuck::bytes_of(&nan_colour));
589            offset += 16;
590            for cp in &clip_plane_data {
591                uniform_data[offset..offset + 16].copy_from_slice(bytemuck::bytes_of(cp));
592                offset += 16;
593            }
594            debug_assert_eq!(offset, 304);
595        }
596
597        let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
598            label: Some("volume_uniform_buf"),
599            size: 304,
600            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
601            mapped_at_creation: true,
602        });
603        {
604            let mut view = uniform_buf.slice(..).get_mapped_range_mut();
605            view.copy_from_slice(&uniform_data);
606        }
607        uniform_buf.unmap();
608
609        let volume_view = &self
610            .content
611            .volume_textures
612            .get(vol_id)
613            .expect("VolumeId validated above")
614            .1;
615
616        let colour_lut_view = if let Some(cmap_id) = item.colour_lut {
617            self.content
618                .colourmap_views
619                .get(cmap_id.0)
620                .unwrap_or(&self.content.fallback_lut_view)
621        } else if let Some(ids) = &self.content.builtin_colourmap_ids {
622            self.content
623                .colourmap_views
624                .get(ids[0].0)
625                .unwrap_or(&self.content.fallback_lut_view)
626        } else {
627            &self.content.fallback_lut_view
628        };
629
630        let opacity_lut_view = if let Some(cmap_id) = item.opacity_lut {
631            self.content
632                .colourmap_views
633                .get(cmap_id.0)
634                .unwrap_or(self.volume.default_opacity_lut_view.as_ref().unwrap())
635        } else {
636            self.volume.default_opacity_lut_view.as_ref().unwrap()
637        };
638
639        let nearest_sampler =
640            crate::resources::builders::clamp_nearest_sampler(device, "volume_nearest_sampler");
641
642        let linear_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
643            label: Some("volume_lut_sampler"),
644            address_mode_u: wgpu::AddressMode::ClampToEdge,
645            address_mode_v: wgpu::AddressMode::ClampToEdge,
646            address_mode_w: wgpu::AddressMode::ClampToEdge,
647            mag_filter: wgpu::FilterMode::Linear,
648            min_filter: wgpu::FilterMode::Linear,
649            mipmap_filter: wgpu::FilterMode::Linear,
650            ..Default::default()
651        });
652
653        let bgl = self
654            .volume
655            .bgl
656            .as_ref()
657            .expect("ensure_volume_pipeline not called");
658
659        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
660            label: Some("volume_bind_group"),
661            layout: bgl,
662            entries: &[
663                wgpu::BindGroupEntry {
664                    binding: 0,
665                    resource: uniform_buf.as_entire_binding(),
666                },
667                wgpu::BindGroupEntry {
668                    binding: 1,
669                    resource: wgpu::BindingResource::TextureView(volume_view),
670                },
671                wgpu::BindGroupEntry {
672                    binding: 2,
673                    resource: wgpu::BindingResource::Sampler(&nearest_sampler),
674                },
675                wgpu::BindGroupEntry {
676                    binding: 3,
677                    resource: wgpu::BindingResource::TextureView(colour_lut_view),
678                },
679                wgpu::BindGroupEntry {
680                    binding: 4,
681                    resource: wgpu::BindingResource::TextureView(opacity_lut_view),
682                },
683                wgpu::BindGroupEntry {
684                    binding: 5,
685                    resource: wgpu::BindingResource::Sampler(&linear_sampler),
686                },
687            ],
688        });
689
690        #[rustfmt::skip]
691        let vertices: [[f32; 3]; 8] = [
692            [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0],
693            [0.0, 0.0, 1.0], [1.0, 0.0, 1.0], [1.0, 1.0, 1.0], [0.0, 1.0, 1.0],
694        ];
695        #[rustfmt::skip]
696        let indices: [u32; 36] = [
697            0,2,1, 0,3,2, 4,5,6, 4,6,7,
698            0,4,7, 0,7,3, 1,2,6, 1,6,5,
699            0,1,5, 0,5,4, 3,7,6, 3,6,2,
700        ];
701
702        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
703            label: Some("volume_cube_vb_frame"),
704            size: std::mem::size_of_val(&vertices) as u64,
705            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
706            mapped_at_creation: true,
707        });
708        {
709            let mut view = vertex_buffer.slice(..).get_mapped_range_mut();
710            view.copy_from_slice(bytemuck::cast_slice(&vertices));
711        }
712        vertex_buffer.unmap();
713
714        let index_buffer = device.create_buffer(&wgpu::BufferDescriptor {
715            label: Some("volume_cube_ib_frame"),
716            size: std::mem::size_of_val(&indices) as u64,
717            usage: wgpu::BufferUsages::INDEX | wgpu::BufferUsages::COPY_DST,
718            mapped_at_creation: true,
719        });
720        {
721            let mut view = index_buffer.slice(..).get_mapped_range_mut();
722            view.copy_from_slice(bytemuck::cast_slice(&indices));
723        }
724        index_buffer.unmap();
725
726        VolumeGpuData {
727            bind_group,
728            vertex_buffer,
729            index_buffer,
730            _dims: dims,
731            _uniform_buf: uniform_buf,
732            wireframe: false,
733        }
734    }
735}
736
737#[cfg(test)]
738mod tests {
739    use crate::DeviceResources;
740    use crate::geometry::marching_cubes::VolumeData;
741    use crate::resources::UploadStatus;
742
743    fn try_make_device() -> Option<(wgpu::Device, wgpu::Queue)> {
744        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
745        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
746            power_preference: wgpu::PowerPreference::LowPower,
747            compatible_surface: None,
748            force_fallback_adapter: false,
749        }))
750        .ok()?;
751        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
752    }
753
754    fn sample_volume_data() -> Vec<f32> {
755        let n: usize = 8;
756        let mut data = Vec::with_capacity(n * n * n);
757        for z in 0..n {
758            for y in 0..n {
759                for x in 0..n {
760                    let v = (x + y + z) as f32 / (3.0 * n as f32);
761                    data.push(v);
762                }
763            }
764        }
765        data
766    }
767
768    fn sample_volume_struct() -> VolumeData {
769        VolumeData {
770            data: sample_volume_data(),
771            dims: [8, 8, 8],
772            origin: [0.0, 0.0, 0.0],
773            spacing: [1.0, 1.0, 1.0],
774        }
775    }
776
777    fn drive_until_ready(
778        resources: &mut DeviceResources,
779        device: &wgpu::Device,
780        queue: &wgpu::Queue,
781        id: crate::resources::JobId,
782        label: &str,
783    ) {
784        for _ in 0..200 {
785            resources.process_uploads(device, queue);
786            match resources.upload_status(id) {
787                UploadStatus::Ready => return,
788                UploadStatus::Failed(e) => panic!("{label} upload failed: {e:?}"),
789                UploadStatus::Pending { .. } => {
790                    std::thread::sleep(std::time::Duration::from_millis(5));
791                }
792                UploadStatus::Unknown => panic!("{label} job id disappeared"),
793            }
794        }
795        panic!("{label} upload did not complete in time");
796    }
797
798    #[test]
799    fn sync_upload_volume_still_works() {
800        let Some((device, queue)) = try_make_device() else {
801            eprintln!("skipping: no wgpu adapter available");
802            return;
803        };
804        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
805        let data = sample_volume_data();
806        let _id = resources.upload_volume(&device, &queue, &data, [8, 8, 8]);
807    }
808
809    #[test]
810    fn begin_upload_volume_validates_dims() {
811        let Some((device, queue)) = try_make_device() else {
812            eprintln!("skipping: no wgpu adapter available");
813            return;
814        };
815        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
816        let err = resources
817            .begin_upload_volume(&device, &queue, vec![0.0_f32; 7], [8, 8, 8])
818            .unwrap_err();
819        assert!(matches!(
820            err,
821            crate::error::ViewportError::VolumeDataLengthMismatch { .. }
822        ));
823    }
824
825    #[test]
826    fn begin_upload_volume_drains_to_handle() {
827        let Some((device, queue)) = try_make_device() else {
828            eprintln!("skipping: no wgpu adapter available");
829            return;
830        };
831        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
832        let job = resources
833            .begin_upload_volume(&device, &queue, sample_volume_data(), [8, 8, 8])
834            .expect("job submitted");
835        drive_until_ready(&mut resources, &device, &queue, job, "volume");
836        let _id = resources.upload_result_volume(job).expect("ready");
837        let err = resources.upload_result_volume(job).unwrap_err();
838        assert!(matches!(
839            err,
840            crate::error::ViewportError::JobResultMissing { .. }
841        ));
842    }
843
844    #[test]
845    fn begin_upload_volume_for_mc_drains_to_handle() {
846        let Some((device, queue)) = try_make_device() else {
847            eprintln!("skipping: no wgpu adapter available");
848            return;
849        };
850        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
851        let job = resources.begin_upload_volume_for_mc(&device, &queue, sample_volume_struct());
852        drive_until_ready(&mut resources, &device, &queue, job, "volume_mc");
853        let _id = resources.upload_result_volume_mc(job).expect("ready");
854    }
855
856    #[test]
857    fn sync_upload_volume_for_mc_still_works() {
858        let Some((device, queue)) = try_make_device() else {
859            eprintln!("skipping: no wgpu adapter available");
860            return;
861        };
862        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
863        let vol = sample_volume_struct();
864        let _id = resources
865            .upload_volume_for_mc(&device, &queue, &vol)
866            .expect("upload ok");
867    }
868}
869
870/// Per-frame GPU data for one volume item, created in `prepare()`.
871pub struct VolumeGpuData {
872    /// Bind group (group 1): volume uniform + 3D texture + sampler + colour LUT + opacity LUT.
873    pub(crate) bind_group: wgpu::BindGroup,
874    /// Vertex buffer for the unit cube bounding box proxy.
875    pub(crate) vertex_buffer: wgpu::Buffer,
876    /// Index buffer for the unit cube (36 indices).
877    pub(crate) index_buffer: wgpu::Buffer,
878    /// Grid dimensions (stored for reference).
879    pub(crate) _dims: [u32; 3],
880    // Keep the uniform buffer alive.
881    pub(crate) _uniform_buf: wgpu::Buffer,
882    /// When true, skip the volume ray-march draw; an OBB wireframe polyline is rendered instead.
883    pub(crate) wireframe: bool,
884}
885
886/// Per-frame GPU data for one image slice item, created in `prepare()`.
887pub(crate) struct ImageSliceGpuData {
888    /// Bind group (group 1): uniform + 3D texture + sampler + LUT + LUT sampler.
889    pub(crate) bind_group: wgpu::BindGroup,
890    // Keep buffers/samplers alive.
891    pub(crate) _uniform_buf: wgpu::Buffer,
892}
893
894/// Per-frame GPU data for one volume surface slice item, created in `prepare()`.
895pub(crate) struct VolumeSurfaceSliceGpuData {
896    /// Bind group (group 1): uniform + 3D texture + sampler + LUT + LUT sampler.
897    pub(crate) bind_group: wgpu::BindGroup,
898    // Keep uniform buffer alive.
899    pub(crate) _uniform_buf: wgpu::Buffer,
900    /// Mesh to draw (vertex + index buffers looked up from mesh_store at render time).
901    pub(crate) mesh_id: crate::resources::mesh::mesh_store::MeshId,
902}