1use 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
10fn 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#[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 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 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 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 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 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 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 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
338pub 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 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 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 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 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 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}