1use crate::spatial::{morton_order, vec4s, GpuKdTree, KD_TREE_WGSL};
10use crate::GpuContext;
11use bytemuck::{Pod, Zeroable};
12use nalgebra::{Isometry3, Matrix3, Matrix6, Translation3, UnitQuaternion, Vector3, Vector6};
13use threecrate_algorithms::{icp_converged, rigid_transform_from_sums, CloudScale};
14use threecrate_core::{Error, Point3f, PointCloud, Result, Vector3f};
15
16const STATS_POINT: usize = 18;
19const STATS_PLANE: usize = 29;
22const WORKGROUP: usize = 128;
24
25fn icp_match_shader() -> String {
26 format!(
27 "{}{}",
28 KD_TREE_WGSL,
29 r#"
30struct IcpParams {
31 // Rows of the current transform; w holds the translation.
32 row0: vec4<f32>,
33 row1: vec4<f32>,
34 row2: vec4<f32>,
35 num_source: u32,
36 max_dist_sq: f32,
37 use_seed: u32,
38 _pad: u32,
39}
40
41const WG: u32 = 128u;
42
43@group(0) @binding(1) var<storage, read> source: array<vec4<f32>>;
44// Matched node per source point (NO_NODE if none); also the seed for the next
45// iteration's search.
46@group(0) @binding(2) var<storage, read_write> matches: array<u32>;
47// Sums per workgroup.
48@group(0) @binding(3) var<storage, read_write> partials: array<f32>;
49@group(0) @binding(4) var<uniform> params: IcpParams;
50// Target normals by original target index (point-to-plane only).
51@group(0) @binding(5) var<storage, read> target_normals: array<vec4<f32>>;
52
53var<workgroup> scratch_point: array<f32, 2304>; // WG * 18
54var<workgroup> scratch_plane: array<f32, 3712>; // WG * 29
55
56struct Match {
57 found: Nearest,
58 moved: vec3<f32>,
59}
60
61// Move source point `i` by the current transform and find its nearest target.
62fn match_point(i: u32) -> Match {
63 let p = source[i].xyz;
64 let q = vec3<f32>(
65 dot(params.row0.xyz, p) + params.row0.w,
66 dot(params.row1.xyz, p) + params.row1.w,
67 dot(params.row2.xyz, p) + params.row2.w,
68 );
69 var seed = NO_NODE;
70 if (params.use_seed == 1u) {
71 seed = matches[i];
72 }
73 let found = find_nearest(q, params.max_dist_sq, seed);
74 matches[i] = found.node;
75 return Match(found, q);
76}
77
78@compute @workgroup_size(128)
79fn icp_match(
80 @builtin(global_invocation_id) gid: vec3<u32>,
81 @builtin(local_invocation_id) lid: vec3<u32>,
82 @builtin(workgroup_id) wid: vec3<u32>,
83) {
84 const STATS: u32 = 18u;
85 var s: array<f32, 18>;
86 for (var j = 0u; j < STATS; j++) {
87 s[j] = 0.0;
88 }
89
90 if (gid.x < params.num_source) {
91 let m = match_point(gid.x);
92 if (m.found.node != NO_NODE) {
93 let q = m.moved;
94 let t = nodes[m.found.node].pos;
95 s[0] = 1.0;
96 s[1] = q.x; s[2] = q.y; s[3] = q.z;
97 s[4] = t.x; s[5] = t.y; s[6] = t.z;
98 s[7] = q.x * t.x; s[8] = q.x * t.y; s[9] = q.x * t.z;
99 s[10] = q.y * t.x; s[11] = q.y * t.y; s[12] = q.y * t.z;
100 s[13] = q.z * t.x; s[14] = q.z * t.y; s[15] = q.z * t.z;
101 s[16] = m.found.dist_sq;
102 s[17] = sqrt(m.found.dist_sq);
103 }
104 }
105
106 // Tree reduction of the per-point values within the workgroup.
107 let base = lid.x * STATS;
108 for (var j = 0u; j < STATS; j++) {
109 scratch_point[base + j] = s[j];
110 }
111 workgroupBarrier();
112 var stride = WG / 2u;
113 loop {
114 if (stride == 0u) {
115 break;
116 }
117 if (lid.x < stride) {
118 let other = (lid.x + stride) * STATS;
119 for (var j = 0u; j < STATS; j++) {
120 scratch_point[base + j] += scratch_point[other + j];
121 }
122 }
123 workgroupBarrier();
124 stride = stride / 2u;
125 }
126 if (lid.x == 0u) {
127 for (var j = 0u; j < STATS; j++) {
128 partials[wid.x * STATS + j] = scratch_point[j];
129 }
130 }
131}
132
133// Point-to-plane: each match adds row a = (q x n, n) and residual
134// b = n . (t - q) to the linearised 6x6 system (Chen & Medioni 1992).
135@compute @workgroup_size(128)
136fn icp_match_plane(
137 @builtin(global_invocation_id) gid: vec3<u32>,
138 @builtin(local_invocation_id) lid: vec3<u32>,
139 @builtin(workgroup_id) wid: vec3<u32>,
140) {
141 const STATS: u32 = 29u;
142 var s: array<f32, 29>;
143 for (var j = 0u; j < STATS; j++) {
144 s[j] = 0.0;
145 }
146
147 if (gid.x < params.num_source) {
148 let m = match_point(gid.x);
149 if (m.found.node != NO_NODE) {
150 let node = nodes[m.found.node];
151 let n = target_normals[node.index].xyz;
152 let q = m.moved;
153 let c = cross(q, n);
154 var a = array<f32, 6>(c.x, c.y, c.z, n.x, n.y, n.z);
155 let b = dot(n, node.pos - q);
156 s[0] = 1.0;
157 s[1] = b * b;
158 var k = 2u;
159 for (var r = 0u; r < 6u; r++) {
160 for (var col = r; col < 6u; col++) {
161 s[k] = a[r] * a[col];
162 k += 1u;
163 }
164 }
165 for (var r = 0u; r < 6u; r++) {
166 s[23u + r] = a[r] * b;
167 }
168 }
169 }
170
171 let base = lid.x * STATS;
172 for (var j = 0u; j < STATS; j++) {
173 scratch_plane[base + j] = s[j];
174 }
175 workgroupBarrier();
176 var stride = WG / 2u;
177 loop {
178 if (stride == 0u) {
179 break;
180 }
181 if (lid.x < stride) {
182 let other = (lid.x + stride) * STATS;
183 for (var j = 0u; j < STATS; j++) {
184 scratch_plane[base + j] += scratch_plane[other + j];
185 }
186 }
187 workgroupBarrier();
188 stride = stride / 2u;
189 }
190 if (lid.x == 0u) {
191 for (var j = 0u; j < STATS; j++) {
192 partials[wid.x * STATS + j] = scratch_plane[j];
193 }
194 }
195}
196"#
197 )
198}
199
200#[repr(C)]
202#[derive(Copy, Clone, Pod, Zeroable)]
203struct IcpParams {
204 row0: [f32; 4],
205 row1: [f32; 4],
206 row2: [f32; 4],
207 num_source: u32,
208 max_dist_sq: f32,
209 use_seed: u32,
210 _pad: u32,
211}
212
213struct IcpSession<'a> {
218 gpu: &'a GpuContext,
219 tree: GpuKdTree,
220 source_scale: CloudScale,
222 num_source: usize,
223 pipeline: &'a wgpu::ComputePipeline,
224 partials: wgpu::Buffer,
225 staging: wgpu::Buffer,
226 params: wgpu::Buffer,
227 bind_group: wgpu::BindGroup,
228 stats_len: usize,
229 max_dist_sq: f32,
230 iteration: usize,
231}
232
233impl<'a> IcpSession<'a> {
234 fn new(
236 gpu: &'a GpuContext,
237 source: &[Point3f],
238 target: &[Point3f],
239 target_normals: Option<&[Vector3f]>,
240 max_correspondence_distance: f32,
241 ) -> Result<Self> {
242 let tree = GpuKdTree::new(gpu, target)?;
243 if tree.is_empty() {
244 return Err(Error::InvalidData(
245 "Target point cloud has no finite points".to_string(),
246 ));
247 }
248 let source_local = tree.frame.points_to_local(source);
249 let source_scale = CloudScale::of(&source_local);
250
251 let uploaded: Vec<Point3f> = morton_order(&source_local)
254 .into_iter()
255 .map(|i| source_local[i as usize])
256 .collect();
257 let source_buffer =
258 gpu.create_buffer_init("ICP Source", &vec4s(&uploaded), wgpu::BufferUsages::STORAGE);
259 let matches = gpu.create_buffer_init(
260 "ICP Matches",
261 &vec![u32::MAX; source.len()],
262 wgpu::BufferUsages::STORAGE,
263 );
264
265 let stats_len = if target_normals.is_some() {
266 STATS_PLANE
267 } else {
268 STATS_POINT
269 };
270 let partials_size =
271 (source.len().div_ceil(WORKGROUP) * stats_len * std::mem::size_of::<f32>()) as u64;
272 let partials = gpu.create_buffer(
273 "ICP Partials",
274 partials_size,
275 wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC,
276 );
277 let staging = gpu.create_staging_buffer("ICP Partials Staging", partials_size);
278 let params = gpu.create_buffer(
279 "ICP Params",
280 std::mem::size_of::<IcpParams>() as u64,
281 wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
282 );
283
284 let mut entries = vec![
285 wgpu::BindGroupEntry {
286 binding: 0,
287 resource: tree.nodes.as_entire_binding(),
288 },
289 wgpu::BindGroupEntry {
290 binding: 1,
291 resource: source_buffer.as_entire_binding(),
292 },
293 wgpu::BindGroupEntry {
294 binding: 2,
295 resource: matches.as_entire_binding(),
296 },
297 wgpu::BindGroupEntry {
298 binding: 3,
299 resource: partials.as_entire_binding(),
300 },
301 wgpu::BindGroupEntry {
302 binding: 4,
303 resource: params.as_entire_binding(),
304 },
305 ];
306 let normals_buffer;
307 let pipeline = if let Some(normals) = target_normals {
308 let data: Vec<[f32; 4]> = normals.iter().map(|n| [n.x, n.y, n.z, 0.0]).collect();
309 normals_buffer =
310 gpu.create_buffer_init("ICP Target Normals", &data, wgpu::BufferUsages::STORAGE);
311 entries.push(wgpu::BindGroupEntry {
312 binding: 5,
313 resource: normals_buffer.as_entire_binding(),
314 });
315 gpu.cached_pipeline(
316 &gpu.pipelines.icp_match_plane,
317 "ICP Match Plane",
318 icp_match_shader,
319 "icp_match_plane",
320 )
321 } else {
322 gpu.cached_pipeline(
323 &gpu.pipelines.icp_match,
324 "ICP Match",
325 icp_match_shader,
326 "icp_match",
327 )
328 };
329 let bind_group =
330 gpu.create_bind_group("ICP Match", &pipeline.get_bind_group_layout(0), &entries);
331
332 let max_dist_sq = if max_correspondence_distance < 0.0 {
334 0.0
335 } else {
336 max_correspondence_distance * max_correspondence_distance
337 };
338
339 Ok(Self {
340 gpu,
341 tree,
342 source_scale,
343 num_source: source.len(),
344 pipeline,
345 partials,
346 staging,
347 params,
348 bind_group,
349 stats_len,
350 max_dist_sq,
351 iteration: 0,
352 })
353 }
354
355 fn match_points(&mut self, transform: &Isometry3<f32>) -> Result<Vec<f64>> {
358 let m = transform.to_homogeneous();
359 let row = |r: usize| [m[(r, 0)], m[(r, 1)], m[(r, 2)], m[(r, 3)]];
360 let params = IcpParams {
361 row0: row(0),
362 row1: row(1),
363 row2: row(2),
364 num_source: self.num_source as u32,
365 max_dist_sq: self.max_dist_sq,
366 use_seed: (self.iteration > 0) as u32,
367 _pad: 0,
368 };
369 self.gpu
370 .queue
371 .write_buffer(&self.params, 0, bytemuck::bytes_of(¶ms));
372
373 let mut encoder = self
374 .gpu
375 .device
376 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
377 label: Some("ICP Match"),
378 });
379 {
380 let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor {
381 label: Some("ICP Match"),
382 timestamp_writes: None,
383 });
384 pass.set_pipeline(self.pipeline);
385 pass.set_bind_group(0, &self.bind_group, &[]);
386 pass.dispatch_workgroups(self.num_source.div_ceil(WORKGROUP) as u32, 1, 1);
387 }
388 encoder.copy_buffer_to_buffer(&self.partials, 0, &self.staging, 0, self.partials.size());
389 self.gpu.queue.submit(std::iter::once(encoder.finish()));
390 self.iteration += 1;
391
392 let partials: Vec<f32> = self.gpu.read_staging(&self.staging)?;
393 let mut totals = vec![0.0f64; self.stats_len];
394 for group in partials.chunks_exact(self.stats_len) {
395 for (total, value) in totals.iter_mut().zip(group) {
396 *total += *value as f64;
397 }
398 }
399 Ok(totals)
400 }
401
402 fn moved_centroid(&self, transform: &Isometry3<f32>) -> Point3f {
404 transform * self.source_scale.centroid
405 }
406}
407
408#[derive(Debug, Clone)]
410pub struct BatchICPJob {
411 pub source: PointCloud<Point3f>,
412 pub target: PointCloud<Point3f>,
413 pub max_iterations: usize,
414 pub convergence_threshold: f32,
415 pub max_correspondence_distance: f32,
416}
417
418#[derive(Debug, Clone)]
420pub struct BatchICPResult {
421 pub transformation: Isometry3<f32>,
422 pub final_error: f32,
423 pub iterations: usize,
424}
425
426impl GpuContext {
427 pub async fn batch_icp_align(&self, jobs: &[BatchICPJob]) -> Result<Vec<BatchICPResult>> {
429 let mut results = Vec::with_capacity(jobs.len());
430 for job in jobs {
431 results.push(
432 self.optimized_icp_align(
433 &job.source,
434 &job.target,
435 job.max_iterations,
436 job.convergence_threshold,
437 job.max_correspondence_distance,
438 )
439 .await?,
440 );
441 }
442 Ok(results)
443 }
444
445 async fn optimized_icp_align(
452 &self,
453 source: &PointCloud<Point3f>,
454 target: &PointCloud<Point3f>,
455 max_iterations: usize,
456 convergence_threshold: f32,
457 max_correspondence_distance: f32,
458 ) -> Result<BatchICPResult> {
459 if source.is_empty() || target.is_empty() {
460 return Err(Error::InvalidData("Empty point clouds".to_string()));
461 }
462
463 let mut session = IcpSession::new(
464 self,
465 &source.points,
466 &target.points,
467 None,
468 max_correspondence_distance,
469 )?;
470 let mut current = Isometry3::identity();
471 let mut previous_mse = f32::INFINITY;
472 let mut final_error = f32::INFINITY;
473 let mut iterations_used = 0;
474
475 for iteration in 0..max_iterations {
476 iterations_used = iteration + 1;
477 let s = session.match_points(¤t)?;
478 let count = s[0];
479 if count < 3.0 {
480 break;
481 }
482 final_error = (s[17] / count) as f32;
483 let mse = (s[16] / count) as f32;
484
485 let delta = rigid_transform_from_sums(
486 count,
487 &Vector3::new(s[1], s[2], s[3]),
488 &Vector3::new(s[4], s[5], s[6]),
489 &Matrix3::new(s[7], s[8], s[9], s[10], s[11], s[12], s[13], s[14], s[15]),
490 )?;
491 let moved_centroid = session.moved_centroid(¤t);
492 current = delta * current;
493
494 if icp_converged(
495 previous_mse,
496 mse,
497 &delta,
498 &moved_centroid,
499 session.source_scale.radius,
500 convergence_threshold,
501 ) {
502 break;
503 }
504 previous_mse = mse;
505 }
506
507 Ok(BatchICPResult {
508 transformation: session.tree.frame.transform_to_world(¤t),
509 final_error,
510 iterations: iterations_used,
511 })
512 }
513
514 pub async fn icp_align(
516 &self,
517 source: &PointCloud<Point3f>,
518 target: &PointCloud<Point3f>,
519 max_iterations: usize,
520 convergence_threshold: f32,
521 max_correspondence_distance: f32,
522 ) -> Result<Isometry3<f32>> {
523 let result = self
524 .optimized_icp_align(
525 source,
526 target,
527 max_iterations,
528 convergence_threshold,
529 max_correspondence_distance,
530 )
531 .await?;
532 Ok(result.transformation)
533 }
534}
535
536#[derive(Debug, Clone)]
538pub struct GpuPointToPlaneICPResult {
539 pub transformation: Isometry3<f32>,
540 pub final_error: f32,
541 pub iterations: usize,
542 pub converged: bool,
543}
544
545impl GpuContext {
546 pub async fn icp_point_to_plane_align(
552 &self,
553 source: &PointCloud<Point3f>,
554 target: &PointCloud<Point3f>,
555 target_normals: &[Vector3f],
556 max_iterations: usize,
557 convergence_threshold: f32,
558 max_correspondence_distance: f32,
559 ) -> Result<GpuPointToPlaneICPResult> {
560 if source.is_empty() || target.is_empty() {
561 return Err(Error::InvalidData("Empty point clouds".to_string()));
562 }
563 if target_normals.len() != target.points.len() {
564 return Err(Error::InvalidData(
565 "target_normals length must equal target point count".to_string(),
566 ));
567 }
568
569 let mut session = IcpSession::new(
570 self,
571 &source.points,
572 &target.points,
573 Some(target_normals),
574 max_correspondence_distance,
575 )?;
576 let mut current = Isometry3::identity();
577 let mut previous_mse = f32::INFINITY;
578 let mut final_error = f32::INFINITY;
579 let mut iterations_used = 0;
580 let mut converged = false;
581
582 for iteration in 0..max_iterations {
583 iterations_used = iteration + 1;
584 let s = session.match_points(¤t)?;
585 let count = s[0];
586 if count < 6.0 {
587 break;
588 }
589 let mse = (s[1] / count) as f32;
590 final_error = mse;
591
592 let delta = solve_point_to_plane(&s[2..23], &s[23..29])?;
593 let moved_centroid = session.moved_centroid(¤t);
594 current = delta * current;
595
596 if icp_converged(
597 previous_mse,
598 mse,
599 &delta,
600 &moved_centroid,
601 session.source_scale.radius,
602 convergence_threshold,
603 ) {
604 converged = true;
605 break;
606 }
607 previous_mse = mse;
608 }
609
610 Ok(GpuPointToPlaneICPResult {
611 transformation: session.tree.frame.transform_to_world(¤t),
612 final_error,
613 iterations: iterations_used,
614 converged,
615 })
616 }
617}
618
619fn solve_point_to_plane(ata_upper: &[f64], atb: &[f64]) -> Result<Isometry3<f32>> {
623 let mut ata = Matrix6::<f64>::zeros();
624 let mut k = 0;
625 for r in 0..6 {
626 for c in r..6 {
627 ata[(r, c)] = ata_upper[k];
628 ata[(c, r)] = ata_upper[k];
629 k += 1;
630 }
631 }
632 let atb = Vector6::from_column_slice(atb);
633
634 let x = if let Some(chol) = ata.cholesky() {
635 chol.solve(&atb)
636 } else {
637 ata.lu().solve(&atb).ok_or_else(|| {
638 Error::Algorithm("Point-to-plane GPU system is ill-conditioned".to_string())
639 })?
640 };
641 let x = x.cast::<f32>();
642
643 let rot_x = UnitQuaternion::from_axis_angle(&nalgebra::Vector3::x_axis(), x[0]);
644 let rot_y = UnitQuaternion::from_axis_angle(&nalgebra::Vector3::y_axis(), x[1]);
645 let rot_z = UnitQuaternion::from_axis_angle(&nalgebra::Vector3::z_axis(), x[2]);
646
647 Ok(Isometry3::from_parts(
648 Translation3::new(x[3], x[4], x[5]),
649 rot_z * rot_y * rot_x,
650 ))
651}
652
653pub async fn gpu_icp(
655 gpu_context: &GpuContext,
656 source: &PointCloud<Point3f>,
657 target: &PointCloud<Point3f>,
658 max_iterations: usize,
659 convergence_threshold: f32,
660 max_correspondence_distance: f32,
661) -> Result<Isometry3<f32>> {
662 gpu_context
663 .icp_align(
664 source,
665 target,
666 max_iterations,
667 convergence_threshold,
668 max_correspondence_distance,
669 )
670 .await
671}
672
673pub async fn gpu_batch_icp(
675 gpu_context: &GpuContext,
676 jobs: &[BatchICPJob],
677) -> Result<Vec<BatchICPResult>> {
678 gpu_context.batch_icp_align(jobs).await
679}
680
681pub async fn gpu_icp_point_to_plane(
696 gpu_context: &GpuContext,
697 source: &PointCloud<Point3f>,
698 target: &PointCloud<Point3f>,
699 target_normals: &[Vector3f],
700 max_iterations: usize,
701 convergence_threshold: f32,
702 max_correspondence_distance: f32,
703) -> Result<GpuPointToPlaneICPResult> {
704 gpu_context
705 .icp_point_to_plane_align(
706 source,
707 target,
708 target_normals,
709 max_iterations,
710 convergence_threshold,
711 max_correspondence_distance,
712 )
713 .await
714}
715
716#[cfg(test)]
717mod tests {
718 use super::*;
719
720 async fn try_create_gpu_context() -> Option<GpuContext> {
721 GpuContext::new().await.ok()
722 }
723
724 fn test_pair(scale: f32) -> (PointCloud<Point3f>, PointCloud<Point3f>, Isometry3<f32>) {
729 let mut source = PointCloud::new();
730 for i in 0..60 {
731 for j in 0..60 {
732 let (x, y) = (i as f32 * 0.1, j as f32 * 0.1);
733 let z = 0.4 * (x * 1.1).sin() * (y * 0.8).cos();
734 source.push(Point3f::new(x + 500.0, y - 200.0, z + 10.0) * scale);
735 }
736 }
737 let centre = Translation3::new(503.0 * scale, -197.0 * scale, 10.0 * scale);
738 let truth = centre
739 * Isometry3::from_parts(
740 Translation3::new(0.02 * scale, -0.01 * scale, 0.01 * scale),
741 UnitQuaternion::from_euler_angles(0.003, -0.002, 0.01),
742 )
743 * centre.inverse();
744 let target = PointCloud::from_points(source.points.iter().map(|p| truth * p).collect());
745 (source, target, truth)
746 }
747
748 fn max_point_error(
749 estimate: &Isometry3<f32>,
750 truth: &Isometry3<f32>,
751 cloud: &[Point3f],
752 ) -> f32 {
753 cloud
754 .iter()
755 .map(|p| (estimate * p - truth * p).norm())
756 .fold(0.0, f32::max)
757 }
758
759 #[test]
760 fn test_gpu_icp_recovers_known_transform() {
761 pollster::block_on(async {
762 let Some(gpu) = try_create_gpu_context().await else {
763 return;
764 };
765 let (source, target, truth) = test_pair(1.0);
766 let estimate = gpu_icp(&gpu, &source, &target, 50, 1e-7, 1.0)
767 .await
768 .unwrap();
769 let error = max_point_error(&estimate, &truth, &source.points);
770 assert!(error < 1e-3, "GPU ICP points are {error} m off");
771
772 let cpu = threecrate_algorithms::icp_point_to_point(
774 &source,
775 &target,
776 Isometry3::identity(),
777 50,
778 1e-7,
779 Some(1.0),
780 )
781 .unwrap();
782 let gap = max_point_error(&estimate, &cpu.transformation, &source.points);
783 assert!(gap < 1e-3, "GPU and CPU ICP differ by {gap} m");
784 });
785 }
786
787 #[test]
788 fn test_gpu_icp_convergence_is_scale_invariant() {
789 pollster::block_on(async {
794 let Some(gpu) = try_create_gpu_context().await else {
795 return;
796 };
797 let mut runs = Vec::new();
798 for scale in [1.0f32, 1000.0] {
799 let (source, target, truth) = test_pair(scale);
800 let result = gpu
801 .optimized_icp_align(&source, &target, 50, 1e-5, scale)
802 .await
803 .unwrap();
804 let error = max_point_error(&result.transformation, &truth, &source.points) / scale;
805 assert!(error < 1e-3, "scale {scale}: {error} (relative) off");
806 runs.push(result.iterations);
807 }
808 assert!(
809 runs[0].abs_diff(runs[1]) <= 1,
810 "iterations differ with scale: {runs:?}"
811 );
812 });
813 }
814
815 #[test]
816 fn test_gpu_point_to_plane_recovers_known_transform() {
817 pollster::block_on(async {
818 let Some(gpu) = try_create_gpu_context().await else {
819 return;
820 };
821 let (source, target, truth) = test_pair(1.0);
822 let normals: Vec<Vector3f> = threecrate_algorithms::estimate_normals(&target, 10)
823 .unwrap()
824 .points
825 .iter()
826 .map(|p| p.normal)
827 .collect();
828 let result = gpu_icp_point_to_plane(&gpu, &source, &target, &normals, 50, 1e-7, 1.0)
829 .await
830 .unwrap();
831 let error = max_point_error(&result.transformation, &truth, &source.points);
832 assert!(
833 error < 1e-3,
834 "GPU point-to-plane ICP points are {error} m off"
835 );
836 assert!(result.converged);
837 });
838 }
839}