Skip to main content

viewport_lib/resources/scivis/
point_cloud.rs

1use super::*;
2
3impl DeviceResources {
4    /// Lazily create the point cloud render pipeline (PointList topology).
5    ///
6    /// No-op if already created. Called from `prepare()` when `frame.scene.point_clouds` is non-empty.
7    pub(crate) fn ensure_point_cloud_pipeline(&mut self, device: &wgpu::Device) {
8        if self.point_cloud_pipeline.is_some() {
9            return;
10        }
11
12        let pc_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
13            label: Some("point_cloud_bgl"),
14            entries: &[
15                wgpu::BindGroupLayoutEntry {
16                    binding: 0,
17                    visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
18                    ty: wgpu::BindingType::Buffer {
19                        ty: wgpu::BufferBindingType::Uniform,
20                        has_dynamic_offset: false,
21                        min_binding_size: None,
22                    },
23                    count: None,
24                },
25                wgpu::BindGroupLayoutEntry {
26                    binding: 1,
27                    visibility: wgpu::ShaderStages::VERTEX,
28                    ty: wgpu::BindingType::Texture {
29                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
30                        view_dimension: wgpu::TextureViewDimension::D2,
31                        multisampled: false,
32                    },
33                    count: None,
34                },
35                wgpu::BindGroupLayoutEntry {
36                    binding: 2,
37                    visibility: wgpu::ShaderStages::VERTEX,
38                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
39                    count: None,
40                },
41                wgpu::BindGroupLayoutEntry {
42                    binding: 3,
43                    visibility: wgpu::ShaderStages::VERTEX,
44                    ty: wgpu::BindingType::Buffer {
45                        ty: wgpu::BufferBindingType::Storage { read_only: true },
46                        has_dynamic_offset: false,
47                        min_binding_size: None,
48                    },
49                    count: None,
50                },
51                wgpu::BindGroupLayoutEntry {
52                    binding: 4,
53                    visibility: wgpu::ShaderStages::VERTEX,
54                    ty: wgpu::BindingType::Buffer {
55                        ty: wgpu::BufferBindingType::Storage { read_only: true },
56                        has_dynamic_offset: false,
57                        min_binding_size: None,
58                    },
59                    count: None,
60                },
61                wgpu::BindGroupLayoutEntry {
62                    binding: 5,
63                    visibility: wgpu::ShaderStages::VERTEX,
64                    ty: wgpu::BindingType::Buffer {
65                        ty: wgpu::BufferBindingType::Storage { read_only: true },
66                        has_dynamic_offset: false,
67                        min_binding_size: None,
68                    },
69                    count: None,
70                },
71                wgpu::BindGroupLayoutEntry {
72                    binding: 6,
73                    visibility: wgpu::ShaderStages::VERTEX,
74                    ty: wgpu::BindingType::Buffer {
75                        ty: wgpu::BufferBindingType::Storage { read_only: true },
76                        has_dynamic_offset: false,
77                        min_binding_size: None,
78                    },
79                    count: None,
80                },
81            ],
82        });
83
84        let shader = crate::resources::builders::wgsl_module(
85            device,
86            "point_cloud_shader",
87            crate::resources::builders::wgsl_source!("point_cloud"),
88        );
89
90        let layout = crate::resources::builders::standard_scene_layout(
91            device,
92            "point_cloud_pipeline_layout",
93            &self.camera_bind_group_layout,
94            &pc_bgl,
95        );
96
97        let pc_vertex_layout = wgpu::VertexBufferLayout {
98            array_stride: 12,
99            step_mode: wgpu::VertexStepMode::Instance,
100            attributes: &[wgpu::VertexAttribute {
101                offset: 0,
102                shader_location: 0,
103                format: wgpu::VertexFormat::Float32x3,
104            }],
105        };
106
107        self.point_cloud_pipeline = Some(crate::resources::builders::build_dual_pipeline(
108            device,
109            &crate::resources::builders::DualPipelineDesc {
110                label: "point_cloud_pipeline",
111                layout: &layout,
112                shader: &shader,
113                vertex_entry: "vs_main",
114                fragment_entry: "fs_main",
115                vertex_buffers: &[pc_vertex_layout.clone()],
116                blend: Some(wgpu::BlendState::ALPHA_BLENDING),
117                topology: wgpu::PrimitiveTopology::TriangleList,
118                cull_mode: None,
119                depth_write: true,
120                depth_compare: wgpu::CompareFunction::Less,
121                sample_count: self.sample_count,
122                ldr_format: self.target_format,
123            },
124        ));
125        self.point_cloud_bgl = Some(pc_bgl);
126    }
127
128    /// Upload one [`PointCloudItem`] to the GPU and return draw data.
129    ///
130    /// Called from `prepare()` for each non-empty item in `frame.scene.point_clouds`.
131    pub(crate) fn upload_point_cloud_per_frame(
132        &mut self,
133        device: &wgpu::Device,
134        queue: &wgpu::Queue,
135        item: &crate::renderer::PointCloudItem,
136    ) -> PointCloudGpuData {
137        let point_count = item.positions.len() as u32;
138
139        let pos_bytes: Vec<u8> = item
140            .positions
141            .iter()
142            .flat_map(|p| bytemuck::bytes_of(p).iter().copied())
143            .collect();
144        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
145            label: Some("pc_vertex_buf"),
146            size: pos_bytes.len().max(12) as u64,
147            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
148            mapped_at_creation: false,
149        });
150        queue.write_buffer(&vertex_buffer, 0, &pos_bytes);
151
152        let (scalar_buf, has_scalars, scalar_min, scalar_max) = if !item.scalars.is_empty() {
153            let min = item
154                .scalar_range
155                .map(|r| r.0)
156                .unwrap_or_else(|| item.scalars.iter().cloned().fold(f32::INFINITY, f32::min));
157            let max = item.scalar_range.map(|r| r.1).unwrap_or_else(|| {
158                item.scalars
159                    .iter()
160                    .cloned()
161                    .fold(f32::NEG_INFINITY, f32::max)
162            });
163            let buf = device.create_buffer(&wgpu::BufferDescriptor {
164                label: Some("pc_scalar_buf"),
165                size: (std::mem::size_of::<f32>() * item.scalars.len()).max(4) as u64,
166                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
167                mapped_at_creation: false,
168            });
169            queue.write_buffer(&buf, 0, bytemuck::cast_slice(&item.scalars));
170            (buf, 1u32, min, max)
171        } else {
172            let buf = device.create_buffer(&wgpu::BufferDescriptor {
173                label: Some("pc_scalar_buf_fallback"),
174                size: 4,
175                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
176                mapped_at_creation: false,
177            });
178            (buf, 0u32, 0.0f32, 1.0f32)
179        };
180
181        let (colour_buf, has_colours) = if !item.colours.is_empty() && has_scalars == 0 {
182            let bytes: &[u8] = bytemuck::cast_slice(&item.colours);
183            let buf = device.create_buffer(&wgpu::BufferDescriptor {
184                label: Some("pc_colour_buf"),
185                size: bytes.len().max(16) as u64,
186                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
187                mapped_at_creation: false,
188            });
189            queue.write_buffer(&buf, 0, bytes);
190            (buf, 1u32)
191        } else {
192            let buf = device.create_buffer(&wgpu::BufferDescriptor {
193                label: Some("pc_colour_buf_fallback"),
194                size: 16,
195                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
196                mapped_at_creation: false,
197            });
198            (buf, 0u32)
199        };
200
201        // Radius buffer: radius_scalars (mapped to radius_range) take priority over
202        // explicit per-point radii.
203        let (radius_buf, has_radius) = if !item.radius_scalars.is_empty() {
204            let r_min = item.radius_scalar_range.map(|r| r.0).unwrap_or_else(|| {
205                item.radius_scalars
206                    .iter()
207                    .cloned()
208                    .fold(f32::INFINITY, f32::min)
209            });
210            let r_max = item.radius_scalar_range.map(|r| r.1).unwrap_or_else(|| {
211                item.radius_scalars
212                    .iter()
213                    .cloned()
214                    .fold(f32::NEG_INFINITY, f32::max)
215            });
216            let range = (r_max - r_min).max(f32::EPSILON);
217            let (out_min, out_max) = item.radius_range;
218            let mapped: Vec<f32> = item
219                .radius_scalars
220                .iter()
221                .map(|&s| {
222                    let t = ((s - r_min) / range).clamp(0.0, 1.0);
223                    out_min + t * (out_max - out_min)
224                })
225                .collect();
226            let buf = device.create_buffer(&wgpu::BufferDescriptor {
227                label: Some("pc_radius_buf"),
228                size: (std::mem::size_of::<f32>() * mapped.len()).max(4) as u64,
229                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
230                mapped_at_creation: false,
231            });
232            queue.write_buffer(&buf, 0, bytemuck::cast_slice(&mapped));
233            (buf, 1u32)
234        } else if !item.radii.is_empty() {
235            let buf = device.create_buffer(&wgpu::BufferDescriptor {
236                label: Some("pc_radius_buf"),
237                size: (std::mem::size_of::<f32>() * item.radii.len()).max(4) as u64,
238                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
239                mapped_at_creation: false,
240            });
241            queue.write_buffer(&buf, 0, bytemuck::cast_slice(&item.radii));
242            (buf, 1u32)
243        } else {
244            let buf = device.create_buffer(&wgpu::BufferDescriptor {
245                label: Some("pc_radius_buf_fallback"),
246                size: 4,
247                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
248                mapped_at_creation: false,
249            });
250            (buf, 0u32)
251        };
252
253        let (transparency_buf, has_transparency) = if !item.transparencies.is_empty() {
254            let buf = device.create_buffer(&wgpu::BufferDescriptor {
255                label: Some("pc_transparency_buf"),
256                size: (std::mem::size_of::<f32>() * item.transparencies.len()).max(4) as u64,
257                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
258                mapped_at_creation: false,
259            });
260            queue.write_buffer(&buf, 0, bytemuck::cast_slice(&item.transparencies));
261            (buf, 1u32)
262        } else {
263            let buf = device.create_buffer(&wgpu::BufferDescriptor {
264                label: Some("pc_transparency_buf_fallback"),
265                size: 4,
266                usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST,
267                mapped_at_creation: false,
268            });
269            (buf, 0u32)
270        };
271
272        #[repr(C)]
273        #[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
274        struct PointCloudUniform {
275            model: [[f32; 4]; 4],
276            default_colour: [f32; 4],
277            point_size: f32,
278            has_scalars: u32,
279            scalar_min: f32,
280            scalar_max: f32,
281            has_colours: u32,
282            has_radius: u32,
283            has_transparency: u32,
284            gaussian: u32,
285            // 0 = ScreenSpaceCircle, 1 = Sphere
286            render_mode: u32,
287            _pad: [u32; 3],
288        }
289        let uniform_data = PointCloudUniform {
290            model: item.model,
291            default_colour: item.default_colour,
292            point_size: item.point_size,
293            has_scalars,
294            scalar_min,
295            scalar_max,
296            has_colours,
297            has_radius,
298            has_transparency,
299            gaussian: if item.gaussian { 1 } else { 0 },
300            render_mode: match item.render_mode {
301                crate::renderer::PointRenderMode::ScreenSpaceCircle => 0,
302                crate::renderer::PointRenderMode::Sphere => 1,
303            },
304            _pad: [0; 3],
305        };
306        let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
307            label: Some("pc_uniform_buf"),
308            size: std::mem::size_of::<PointCloudUniform>() as u64,
309            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
310            mapped_at_creation: false,
311        });
312        queue.write_buffer(&uniform_buf, 0, bytemuck::bytes_of(&uniform_data));
313
314        let lut_view = self
315            .content
316            .builtin_colourmap_ids
317            .and_then(|ids| {
318                let preset_id = item
319                    .colourmap_id
320                    .unwrap_or(ids[crate::resources::BuiltinColourmap::Viridis as usize]);
321                self.content.colourmap_views.get(preset_id.0)
322            })
323            .unwrap_or(&self.content.fallback_lut_view);
324
325        let lut_sampler = &self.material_sampler;
326
327        let bgl = self
328            .point_cloud_bgl
329            .as_ref()
330            .expect("ensure_point_cloud_pipeline not called");
331        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
332            label: Some("pc_bind_group"),
333            layout: bgl,
334            entries: &[
335                wgpu::BindGroupEntry {
336                    binding: 0,
337                    resource: uniform_buf.as_entire_binding(),
338                },
339                wgpu::BindGroupEntry {
340                    binding: 1,
341                    resource: wgpu::BindingResource::TextureView(lut_view),
342                },
343                wgpu::BindGroupEntry {
344                    binding: 2,
345                    resource: wgpu::BindingResource::Sampler(lut_sampler),
346                },
347                wgpu::BindGroupEntry {
348                    binding: 3,
349                    resource: scalar_buf.as_entire_binding(),
350                },
351                wgpu::BindGroupEntry {
352                    binding: 4,
353                    resource: colour_buf.as_entire_binding(),
354                },
355                wgpu::BindGroupEntry {
356                    binding: 5,
357                    resource: radius_buf.as_entire_binding(),
358                },
359                wgpu::BindGroupEntry {
360                    binding: 6,
361                    resource: transparency_buf.as_entire_binding(),
362                },
363            ],
364        });
365
366        PointCloudGpuData {
367            vertex_buffer,
368            point_count,
369            bind_group,
370            _uniform_buf: uniform_buf,
371            _scalar_buf: scalar_buf,
372            _colour_buf: colour_buf,
373            _radius_buf: radius_buf,
374            _transparency_buf: transparency_buf,
375        }
376    }
377
378    /// Pre-upload a point cloud and return a typed handle.
379    pub fn upload_point_cloud(
380        &mut self,
381        device: &wgpu::Device,
382        queue: &wgpu::Queue,
383        item: &crate::renderer::PointCloudItem,
384    ) -> crate::resources::PointCloudId {
385        self.ensure_point_cloud_pipeline(device);
386        let gpu = self.upload_point_cloud_per_frame(device, queue, item);
387        self.content.point_cloud_store.insert(gpu)
388    }
389
390    /// Remove a pre-uploaded point cloud.
391    pub fn drop_point_cloud(&mut self, id: crate::resources::PointCloudId) -> bool {
392        self.content.point_cloud_store.remove(id)
393    }
394
395    /// Replace the geometry of a pre-uploaded point cloud, keeping the same id.
396    pub fn replace_point_cloud(
397        &mut self,
398        device: &wgpu::Device,
399        queue: &wgpu::Queue,
400        id: crate::resources::PointCloudId,
401        item: &crate::renderer::PointCloudItem,
402    ) -> bool {
403        if !self.content.point_cloud_store.contains(id) {
404            return false;
405        }
406        self.ensure_point_cloud_pipeline(device);
407        let gpu = self.upload_point_cloud_per_frame(device, queue, item);
408        self.content.point_cloud_store.replace(id, gpu)
409    }
410
411    /// Start an asynchronous point cloud upload.
412    pub fn begin_upload_point_cloud(
413        &mut self,
414        device: &wgpu::Device,
415        queue: &wgpu::Queue,
416        item: crate::renderer::PointCloudItem,
417    ) -> crate::resources::JobId {
418        let slot = crate::resources::ResultSlot::<crate::resources::PointCloudId>::new();
419        let slot_for_apply = slot.clone();
420        let device_for_apply = device.clone();
421        let queue_for_apply = queue.clone();
422        let id = {
423            let mut runner = self.jobs.lock().expect("upload job runner poisoned");
424            runner.submit_cpu(move |progress| {
425                progress.set(0.9);
426                Ok(crate::resources::upload_jobs::JobProduct::with_apply(
427                    Box::new(move |resources: &mut DeviceResources| {
428                        let pid = resources.upload_point_cloud(
429                            &device_for_apply,
430                            &queue_for_apply,
431                            &item,
432                        );
433                        slot_for_apply.set(pid);
434                    }),
435                ))
436            })
437        };
438        self.job_results
439            .point_cloud
440            .lock()
441            .expect("point cloud result map poisoned")
442            .insert(id, slot);
443        id
444    }
445
446    /// Take the [`PointCloudId`](crate::resources::PointCloudId) produced by a
447    /// completed [`begin_upload_point_cloud`](Self::begin_upload_point_cloud) job.
448    pub fn upload_result_point_cloud(
449        &mut self,
450        id: crate::resources::JobId,
451    ) -> crate::error::ViewportResult<crate::resources::PointCloudId> {
452        let mut map = self
453            .job_results
454            .point_cloud
455            .lock()
456            .expect("point cloud result map poisoned");
457        let slot = match map.get(&id) {
458            Some(s) => s.clone(),
459            None => {
460                return Err(crate::error::ViewportError::JobResultMissing {
461                    reason: "unknown id or wrong upload type",
462                });
463            }
464        };
465        match slot.take() {
466            Some(pid) => {
467                map.remove(&id);
468                Ok(pid)
469            }
470            None => Err(crate::error::ViewportError::JobNotReady),
471        }
472    }
473}
474
475#[cfg(test)]
476mod tests {
477    use crate::DeviceResources;
478    use crate::renderer::PointCloudItem;
479    use crate::resources::UploadStatus;
480
481    fn try_make_device() -> Option<(wgpu::Device, wgpu::Queue)> {
482        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor::default());
483        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
484            power_preference: wgpu::PowerPreference::LowPower,
485            compatible_surface: None,
486            force_fallback_adapter: false,
487        }))
488        .ok()?;
489        pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()
490    }
491
492    fn sample_point_cloud() -> PointCloudItem {
493        let mut item = PointCloudItem::default();
494        item.positions = vec![
495            [0.0, 0.0, 0.0],
496            [1.0, 0.0, 0.0],
497            [0.0, 1.0, 0.0],
498            [0.0, 0.0, 1.0],
499        ];
500        item.point_size = 6.0;
501        item
502    }
503
504    #[test]
505    fn upload_point_cloud_returns_valid_handle() {
506        let Some((device, queue)) = try_make_device() else {
507            eprintln!("skipping: no wgpu adapter available");
508            return;
509        };
510        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
511        let id = resources.upload_point_cloud(&device, &queue, &sample_point_cloud());
512        assert!(resources.content.point_cloud_store.contains(id));
513        assert!(resources.drop_point_cloud(id));
514        assert!(!resources.content.point_cloud_store.contains(id));
515    }
516
517    #[test]
518    fn begin_upload_point_cloud_drains_to_handle() {
519        let Some((device, queue)) = try_make_device() else {
520            eprintln!("skipping: no wgpu adapter available");
521            return;
522        };
523        let mut resources = DeviceResources::new(&device, wgpu::TextureFormat::Rgba8UnormSrgb, 1);
524        let job = resources.begin_upload_point_cloud(&device, &queue, sample_point_cloud());
525        for _ in 0..200 {
526            resources.process_uploads(&device, &queue);
527            match resources.upload_status(job) {
528                UploadStatus::Ready => break,
529                UploadStatus::Failed(e) => panic!("upload failed: {e:?}"),
530                UploadStatus::Pending { .. } => {
531                    std::thread::sleep(std::time::Duration::from_millis(5));
532                }
533                UploadStatus::Unknown => panic!("job id disappeared"),
534            }
535        }
536        let id = resources.upload_result_point_cloud(job).expect("ready");
537        assert!(resources.content.point_cloud_store.contains(id));
538        let err = resources.upload_result_point_cloud(job).unwrap_err();
539        assert!(matches!(
540            err,
541            crate::error::ViewportError::JobResultMissing { .. }
542        ));
543    }
544}
545
546/// Per-frame GPU data for one point cloud item, created in `prepare()`.
547#[derive(Clone)]
548pub struct PointCloudGpuData {
549    /// Vertex buffer: one entry per point, packed as `[position: vec3, _pad: f32]` (16 bytes).
550    /// The shader reads colour/scalar from storage buffers indexed by `vertex_index`.
551    pub(crate) vertex_buffer: wgpu::Buffer,
552    /// Number of points (= draw count).
553    pub(crate) point_count: u32,
554    /// Bind group (group 1): uniform + LUT + sampler + scalar + colour + radius + transparency.
555    pub(crate) bind_group: wgpu::BindGroup,
556    // Keep the buffers alive for the lifetime of this struct.
557    pub(crate) _uniform_buf: wgpu::Buffer,
558    pub(crate) _scalar_buf: wgpu::Buffer,
559    pub(crate) _colour_buf: wgpu::Buffer,
560    pub(crate) _radius_buf: wgpu::Buffer,
561    pub(crate) _transparency_buf: wgpu::Buffer,
562}