Skip to main content

threecrate_gpu/
normals.rs

1//! GPU-accelerated normal estimation
2
3use crate::spatial::{vec4, GpuKdTree, KD_TREE_WGSL, MAX_K};
4use crate::GpuContext;
5use bytemuck::{Pod, Zeroable};
6use rayon::prelude::*;
7use threecrate_algorithms::KdTree;
8use threecrate_core::{NormalPoint3f, Point3f, PointCloud, Result};
9
10/// Normals kernel: per point, find its neighbors in the GPU kd-tree, build the
11/// neighborhood covariance, and take the eigenvector of its smallest
12/// eigenvalue. Matches the CPU `estimate_normals`: the k nearest neighbors
13/// (not counting the point itself) plus the point.
14fn normals_shader() -> String {
15    format!(
16        "{}{}",
17        KD_TREE_WGSL,
18        r#"
19struct NormalParams {
20    num_points: u32,
21    k: u32,
22    consistent_orientation: u32,
23    _pad: u32,
24    viewpoint: vec4<f32>,
25}
26
27@group(0) @binding(2) var<storage, read_write> output_normals: array<vec4<f32>>;
28@group(0) @binding(3) var<uniform> params: NormalParams;
29
30// Eigenvector for the smallest eigenvalue of the symmetric matrix
31// [[a00 a01 a02] [a01 a11 a12] [a02 a12 a22]], via the closed-form
32// (trigonometric) eigenvalues and a cross product of two rows of A - lambda*I.
33fn smallest_eigenvector(c00: f32, c01: f32, c02: f32, c11: f32, c12: f32, c22: f32) -> vec3<f32> {
34    // Scale to unit size so f32 keeps its precision for tiny or huge clouds.
35    let scale = max(max(max(abs(c00), abs(c01)), max(abs(c02), abs(c11))), max(abs(c12), abs(c22)));
36    if (scale <= 0.0) {
37        return vec3<f32>(0.0, 0.0, 1.0);
38    }
39    let a00 = c00 / scale;
40    let a01 = c01 / scale;
41    let a02 = c02 / scale;
42    let a11 = c11 / scale;
43    let a12 = c12 / scale;
44    let a22 = c22 / scale;
45
46    let p1 = a01 * a01 + a02 * a02 + a12 * a12;
47    let q = (a00 + a11 + a22) / 3.0;
48    let b00 = a00 - q;
49    let b11 = a11 - q;
50    let b22 = a22 - q;
51    let p2 = b00 * b00 + b11 * b11 + b22 * b22 + 2.0 * p1;
52    if (p2 < 1e-12) {
53        // All eigenvalues equal: no preferred direction.
54        return vec3<f32>(0.0, 0.0, 1.0);
55    }
56    let p = sqrt(p2 / 6.0);
57    let det_b = b00 * (b11 * b22 - a12 * a12) - a01 * (a01 * b22 - a12 * a02) + a02 * (a01 * a12 - b11 * a02);
58    let r = clamp(det_b / (2.0 * p * p * p), -1.0, 1.0);
59    let phi = acos(r) / 3.0;
60    // Eigenvalues are q + 2p cos(phi + 2 pi j / 3); j = 1 gives the smallest.
61    let lambda = q + 2.0 * p * cos(phi + 2.0943951);
62
63    let r0 = vec3<f32>(a00 - lambda, a01, a02);
64    let r1 = vec3<f32>(a01, a11 - lambda, a12);
65    let r2 = vec3<f32>(a02, a12, a22 - lambda);
66    let x01 = cross(r0, r1);
67    let x02 = cross(r0, r2);
68    let x12 = cross(r1, r2);
69    let d01 = dot(x01, x01);
70    let d02 = dot(x02, x02);
71    let d12 = dot(x12, x12);
72    var best = x01;
73    var best_len = d01;
74    if (d02 > best_len) {
75        best = x02;
76        best_len = d02;
77    }
78    if (d12 > best_len) {
79        best = x12;
80        best_len = d12;
81    }
82    if (best_len < 1e-20) {
83        // Two smallest eigenvalues equal (points on a line): any perpendicular works.
84        return vec3<f32>(0.0, 0.0, 1.0);
85    }
86    return best / sqrt(best_len);
87}
88
89@compute @workgroup_size(64)
90fn normals(@builtin(global_invocation_id) gid: vec3<u32>) {
91    // One thread per tree node rather than per input point: nodes are stored
92    // in tree order, so neighboring threads search neighboring parts of the
93    // cloud, which keeps them in step and their memory reads cached.
94    let node_id = gid.x;
95    if (node_id >= arrayLength(&nodes) || node_id >= params.num_points) {
96        return;
97    }
98    let index = nodes[node_id].index;
99    let center = nodes[node_id].pos;
100
101    // k + 1 so the point itself (distance 0) can be dropped and still leave k.
102    find_k_nearest(center, params.k + 1u, 3.4e38);
103
104    // Neighborhood = up to k neighbors other than the point, plus the point.
105    var sum = center;
106    var count = 1u;
107    for (var i = 0u; i < knn_count; i++) {
108        let node = nodes[knn_node[i]];
109        if (node.index != index && count <= params.k) {
110            sum += node.pos;
111            count += 1u;
112        }
113    }
114    if (count < 4u) {
115        output_normals[index] = vec4<f32>(0.0, 0.0, 1.0, 0.0);
116        return;
117    }
118    let centroid = sum / f32(count);
119
120    let dc = center - centroid;
121    var c00 = dc.x * dc.x;
122    var c01 = dc.x * dc.y;
123    var c02 = dc.x * dc.z;
124    var c11 = dc.y * dc.y;
125    var c12 = dc.y * dc.z;
126    var c22 = dc.z * dc.z;
127    var used = 1u;
128    for (var i = 0u; i < knn_count; i++) {
129        let node = nodes[knn_node[i]];
130        if (node.index != index && used <= params.k) {
131            let d = node.pos - centroid;
132            c00 += d.x * d.x;
133            c01 += d.x * d.y;
134            c02 += d.x * d.z;
135            c11 += d.y * d.y;
136            c12 += d.y * d.z;
137            c22 += d.z * d.z;
138            used += 1u;
139        }
140    }
141
142    var normal = smallest_eigenvector(c00, c01, c02, c11, c12, c22);
143    if (params.consistent_orientation == 1u) {
144        let to_view = params.viewpoint.xyz - center;
145        if (dot(normal, to_view) < 0.0) {
146            normal = -normal;
147        }
148    }
149    output_normals[index] = vec4<f32>(normal, 0.0);
150}
151"#
152    )
153}
154
155/// Uniform block of the normals kernel (`NormalParams` in WGSL).
156#[repr(C)]
157#[derive(Copy, Clone, Pod, Zeroable)]
158struct NormalParams {
159    num_points: u32,
160    k: u32,
161    consistent_orientation: u32,
162    _pad: u32,
163    viewpoint: [f32; 4],
164}
165
166impl GpuContext {
167    /// Compute normals for a point cloud using GPU acceleration with options.
168    ///
169    /// Uses the `k` nearest neighbors of each point (plus the point), like the
170    /// CPU `estimate_normals`. `k` is clamped to 3..=31. With
171    /// `consistent_orientation`, normals point towards `viewpoint`, or by
172    /// default towards a point above the cloud's bounding-box centre.
173    pub async fn compute_normals_with_options(
174        &self,
175        points: &[Point3f],
176        k_neighbors: usize,
177        consistent_orientation: bool,
178        viewpoint: Option<[f32; 3]>,
179    ) -> Result<Vec<nalgebra::Vector3<f32>>> {
180        if points.is_empty() {
181            return Ok(Vec::new());
182        }
183        let k = k_neighbors.clamp(3, MAX_K - 1);
184
185        let tree = GpuKdTree::new(self, points)?;
186        if tree.is_empty() {
187            return Ok(vec![nalgebra::Vector3::z(); points.len()]);
188        }
189        let frame = tree.frame;
190
191        // Default viewpoint (same as the CPU path): above the bounding-box
192        // centre by the bounding-box diagonal.
193        let viewpoint = viewpoint.unwrap_or_else(|| {
194            let (min, max) =
195                points
196                    .iter()
197                    .fold(([f32::MAX; 3], [f32::MIN; 3]), |(mut lo, mut hi), p| {
198                        for i in 0..3 {
199                            lo[i] = lo[i].min(p[i]);
200                            hi[i] = hi[i].max(p[i]);
201                        }
202                        (lo, hi)
203                    });
204            let extent =
205                ((max[0] - min[0]).powi(2) + (max[1] - min[1]).powi(2) + (max[2] - min[2]).powi(2))
206                    .sqrt();
207            [
208                (min[0] + max[0]) * 0.5,
209                (min[1] + max[1]) * 0.5,
210                (min[2] + max[2]) * 0.5 + extent,
211            ]
212        });
213
214        // Points with a NaN or infinite coordinate have no tree node, so the
215        // kernel never writes them; they keep this default unit normal.
216        let output = self.create_buffer_init(
217            "Normals Output",
218            &vec![[0.0f32, 0.0, 1.0, 0.0]; points.len()],
219            wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
220        );
221        let params = NormalParams {
222            // Threads run per tree node; non-finite points have no node.
223            num_points: tree.node_to_index.len() as u32,
224            k: k as u32,
225            consistent_orientation: consistent_orientation as u32,
226            _pad: 0,
227            viewpoint: vec4(&frame.point_to_local(&Point3f::from(viewpoint))),
228        };
229        let params_buffer =
230            self.create_buffer_init("Normals Params", &[params], wgpu::BufferUsages::UNIFORM);
231
232        let pipeline = self.cached_pipeline(
233            &self.pipelines.normals,
234            "Normals",
235            normals_shader,
236            "normals",
237        );
238        let bind_group = self.create_bind_group(
239            "Normals",
240            &pipeline.get_bind_group_layout(0),
241            &[
242                wgpu::BindGroupEntry {
243                    binding: 0,
244                    resource: tree.nodes.as_entire_binding(),
245                },
246                wgpu::BindGroupEntry {
247                    binding: 2,
248                    resource: output.as_entire_binding(),
249                },
250                wgpu::BindGroupEntry {
251                    binding: 3,
252                    resource: params_buffer.as_entire_binding(),
253                },
254            ],
255        );
256
257        let mut encoder = self
258            .device
259            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
260                label: Some("Normals"),
261            });
262        {
263            let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
264                label: Some("Normals"),
265                timestamp_writes: None,
266            });
267            pass.set_pipeline(pipeline);
268            pass.set_bind_group(0, &bind_group, &[]);
269            pass.dispatch_workgroups(tree.node_to_index.len().div_ceil(64) as u32, 1, 1);
270        }
271        self.queue.submit(std::iter::once(encoder.finish()));
272
273        let normals: Vec<[f32; 4]> = self.read_buffer(&output)?;
274        Ok(normals
275            .into_iter()
276            .map(|n| nalgebra::Vector3::new(n[0], n[1], n[2]))
277            .collect())
278    }
279
280    /// Compute normals for a point cloud using GPU acceleration with default options
281    pub async fn compute_normals(
282        &self,
283        points: &[Point3f],
284        k_neighbors: usize,
285    ) -> Result<Vec<nalgebra::Vector3<f32>>> {
286        self.compute_normals_with_options(points, k_neighbors, true, None)
287            .await
288    }
289
290    /// The `k` nearest neighbors of every point (not counting the point
291    /// itself), padded to 64 slots by repeating the last neighbor. Used by the
292    /// GPU statistical outlier filter. Runs on the CPU with a kd-tree.
293    pub fn compute_neighbors_simple(&self, points: &[[f32; 3]], k: usize) -> Vec<[u32; 64]> {
294        let k = k.min(64).min(points.len().saturating_sub(1));
295        if points.is_empty() {
296            return Vec::new();
297        }
298        let cloud: Vec<Point3f> = points
299            .iter()
300            .map(|p| Point3f::new(p[0], p[1], p[2]))
301            .collect();
302        let Ok(tree) = KdTree::new(&cloud) else {
303            return vec![[0; 64]; points.len()];
304        };
305
306        cloud
307            .par_iter()
308            .enumerate()
309            .map_init(Vec::new, |knn, (i, point)| {
310                tree.find_k_nearest_into(point, k + 1, knn);
311                let mut neighbors = [i as u32; 64];
312                let mut count = 0;
313                for &(index, _) in knn.iter() {
314                    if index != i && count < k {
315                        neighbors[count] = index as u32;
316                        count += 1;
317                    }
318                }
319                if count > 0 {
320                    let last = neighbors[count - 1];
321                    neighbors[count..].fill(last);
322                }
323                neighbors
324            })
325            .collect()
326    }
327
328    /// Helper to compute neighbors from Vec<[f32;3]> built from owned data
329    pub fn compute_neighbors_simple_points3(
330        &self,
331        points: &[[f32; 3]],
332        k: usize,
333    ) -> Vec<[u32; 64]> {
334        self.compute_neighbors_simple(points, k)
335    }
336}
337
338/// GPU-accelerated normal estimation for point clouds
339pub async fn gpu_estimate_normals(
340    gpu_context: &GpuContext,
341    cloud: &mut PointCloud<Point3f>,
342    k: usize,
343) -> Result<PointCloud<NormalPoint3f>> {
344    let normals = gpu_context.compute_normals(&cloud.points, k).await?;
345
346    let normal_points: Vec<NormalPoint3f> = cloud
347        .points
348        .iter()
349        .zip(normals.iter())
350        .map(|(point, normal)| NormalPoint3f {
351            position: *point,
352            normal: *normal,
353        })
354        .collect();
355
356    Ok(PointCloud::from_points(normal_points))
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362    use threecrate_core::{Point3f, PointCloud};
363
364    /// Try to create a GPU context, return None if not available
365    async fn try_create_gpu_context() -> Option<crate::GpuContext> {
366        match crate::GpuContext::new().await {
367            Ok(gpu) => Some(gpu),
368            Err(_) => {
369                println!("⚠️  GPU not available, skipping GPU-dependent test");
370                None
371            }
372        }
373    }
374
375    #[tokio::test]
376    async fn test_gpu_normals_plane() {
377        let Some(gpu) = try_create_gpu_context().await else {
378            return;
379        };
380
381        let mut cloud = PointCloud::new();
382        // Create XY plane grid
383        for i in 0..15 {
384            for j in 0..15 {
385                cloud.push(Point3f::new(i as f32 * 0.1, j as f32 * 0.1, 0.0));
386            }
387        }
388        let result = gpu_estimate_normals(&gpu, &mut cloud, 8).await.unwrap();
389        assert_eq!(result.len(), 225);
390        let mut z_count = 0;
391        for p in result.iter() {
392            if p.normal.z.abs() > 0.8 {
393                z_count += 1;
394            }
395        }
396        let pct = (z_count as f32 / result.len() as f32) * 100.0;
397        assert!(pct > 80.0, "Only {:.1}% normals in Z direction", pct);
398    }
399
400    #[tokio::test]
401    async fn test_gpu_normals_compare_cpu_plane() {
402        use threecrate_algorithms::estimate_normals as cpu_estimate_normals;
403        let Some(gpu) = try_create_gpu_context().await else {
404            return;
405        };
406
407        let mut cloud = PointCloud::new();
408        for i in 0..10 {
409            for j in 0..10 {
410                cloud.push(Point3f::new(i as f32 * 0.1, j as f32 * 0.1, 0.0));
411            }
412        }
413        let gpu_cloud = gpu_estimate_normals(&gpu, &mut cloud.clone(), 8)
414            .await
415            .unwrap();
416        let cpu_cloud = cpu_estimate_normals(&cloud, 8).unwrap();
417        // Compare orientation alignment percentage
418        let mut agree = 0usize;
419        for (g, c) in gpu_cloud.iter().zip(cpu_cloud.iter()) {
420            let dot = g.normal.dot(&c.normal);
421            if dot.abs() > 0.7 {
422                agree += 1;
423            }
424        }
425        let pct = (agree as f32 / gpu_cloud.len() as f32) * 100.0;
426        assert!(pct > 70.0, "GPU-CPU normals agree only {:.1}%", pct);
427    }
428
429    #[tokio::test]
430    async fn test_gpu_normals_non_finite_point_gets_unit_normal() {
431        let Some(gpu) = try_create_gpu_context().await else {
432            return;
433        };
434        let mut cloud = PointCloud::new();
435        for i in 0..10 {
436            for j in 0..10 {
437                cloud.push(Point3f::new(i as f32 * 0.1, j as f32 * 0.1, 0.0));
438            }
439        }
440        cloud.push(Point3f::new(f32::NAN, 0.0, 0.0));
441        let normals = gpu_estimate_normals(&gpu, &mut cloud, 8).await.unwrap();
442        assert_eq!(
443            normals.points[100].normal,
444            nalgebra::Vector3::new(0.0, 0.0, 1.0)
445        );
446        assert!(normals.points[..100]
447            .iter()
448            .all(|p| (p.normal.norm() - 1.0).abs() < 1e-4));
449    }
450
451    #[tokio::test]
452    async fn test_gpu_normals_match_cpu_on_curved_surface() {
453        use threecrate_algorithms::estimate_normals as cpu_estimate_normals;
454        let Some(gpu) = try_create_gpu_context().await else {
455            return;
456        };
457
458        // Curved, slightly irregular surface far from the origin
459        let mut cloud = PointCloud::new();
460        for i in 0..120 {
461            for j in 0..120 {
462                let x = i as f32 * 0.05 + 0.013 * ((i * 7 + j * 3) % 5) as f32;
463                let y = j as f32 * 0.05 + 0.011 * ((i * 3 + j * 5) % 7) as f32;
464                let z = 0.5 * (x * 1.3).sin() * (y * 0.9).cos();
465                cloud.push(Point3f::new(x + 1000.0, y - 500.0, z + 20.0));
466            }
467        }
468
469        let gpu_cloud = gpu_estimate_normals(&gpu, &mut cloud.clone(), 10)
470            .await
471            .unwrap();
472        let cpu_cloud = cpu_estimate_normals(&cloud, 10).unwrap();
473
474        // Same direction and same orientation (both point to the viewpoint)
475        let agree = gpu_cloud
476            .iter()
477            .zip(cpu_cloud.iter())
478            .filter(|(g, c)| g.normal.dot(&c.normal) > 0.999)
479            .count();
480        let pct = 100.0 * agree as f32 / gpu_cloud.len() as f32;
481        assert!(
482            pct > 99.0,
483            "GPU and CPU normals agree on only {pct:.2}% of points"
484        );
485    }
486}