Skip to main content

threecrate_gpu/
icp.rs

1//! GPU-accelerated ICP
2//!
3//! The target cloud is uploaded once as a kd-tree. Each iteration runs one
4//! kernel that moves every source point by the current estimate, finds its
5//! nearest target point in the tree (starting from last iteration's match), and
6//! sums the values the update needs within each workgroup. Only those small
7//! per-workgroup sums come back to the CPU.
8
9use 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
16/// Sums per workgroup for point-to-point: count, source sum (3), target sum
17/// (3), source * target^T (9), squared distance, distance.
18const STATS_POINT: usize = 18;
19/// Sums per workgroup for point-to-plane: count, squared residual, the upper
20/// triangle of A^T A (21), and A^T b (6).
21const STATS_PLANE: usize = 29;
22/// Workgroup size of the matching kernels (`WG` in WGSL).
23const 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/// Uniform block of the matching kernels (`IcpParams` in WGSL).
201#[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
213/// GPU state for one ICP run: target tree, source points, and the buffers the
214/// matching kernel reads and writes. Everything stays on the GPU between
215/// iterations except the small per-workgroup sums, which are copied into one
216/// staging buffer that is reused every iteration.
217struct IcpSession<'a> {
218    gpu: &'a GpuContext,
219    tree: GpuKdTree,
220    /// Size of the source cloud (centroid and spread) in the tree's frame.
221    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    /// `target_normals` selects point-to-plane (`Some`) or point-to-point.
235    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        // Upload in Morton order so neighboring GPU threads search neighboring
252        // parts of the target tree. The sums do not depend on point order.
253        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        // Same cutoff semantics as the CPU ICP: negative rejects everything.
333        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    /// Match every source point (moved by `transform`, in the tree's frame) to
356    /// its nearest target point on the GPU, and return the summed statistics.
357    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(&params));
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    /// The source centroid moved by `transform`, for the convergence check.
403    fn moved_centroid(&self, transform: &Isometry3<f32>) -> Point3f {
404        transform * self.source_scale.centroid
405    }
406}
407
408/// Batch ICP operation for multiple point cloud pairs
409#[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/// Result of a batch ICP operation
419#[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    /// Execute multiple ICP operations in parallel batches
428    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    /// Point-to-point ICP with GPU matching.
446    ///
447    /// Uses the same stopping rule as the CPU ICP: stop when the RMSE improves
448    /// by less than `convergence_threshold` as a fraction, or an update barely
449    /// moves the points relative to the cloud's size. `final_error` is the
450    /// mean correspondence distance of the last iteration.
451    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(&current)?;
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(&current);
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(&current),
509            final_error,
510            iterations: iterations_used,
511        })
512    }
513
514    /// GPU-accelerated ICP alignment (original single implementation)
515    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/// Result of GPU point-to-plane ICP
537#[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    /// GPU-accelerated point-to-plane ICP.
547    ///
548    /// The GPU matches points and sums the linearised 6×6 system
549    /// (Chen & Medioni 1992); the CPU only solves it. Stops with the same rule
550    /// as the CPU ICP (see [`icp_converged`]).
551    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(&current)?;
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(&current);
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(&current),
612            final_error,
613            iterations: iterations_used,
614            converged,
615        })
616    }
617}
618
619/// Solve the linearised point-to-plane system `AᵀA x = Aᵀb` given the upper
620/// triangle of `AᵀA` (row-major, 21 values) and `Aᵀb`, and turn
621/// `x = (rx, ry, rz, tx, ty, tz)` into a rigid update.
622fn 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
653/// GPU-accelerated ICP registration
654pub 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
673/// Execute batch ICP operations on multiple point cloud pairs
674pub 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
681/// GPU-accelerated point-to-plane ICP registration.
682///
683/// The GPU finds correspondences and sums the linearised 6×6 system; the CPU
684/// only solves it (Chen & Medioni 1992).
685///
686/// # Arguments
687/// * `gpu_context`                  - Initialized GPU context
688/// * `source`                       - Source point cloud
689/// * `target`                       - Target point cloud
690/// * `target_normals`               - Surface normals at each target point
691/// * `max_iterations`               - Maximum number of iterations
692/// * `convergence_threshold`        - Stop when the RMSE improves by less than this
693///   fraction, or an update barely moves the points (same rule as the CPU ICP)
694/// * `max_correspondence_distance`  - Maximum distance for valid correspondences
695pub 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    /// Curved grid far from the origin, scaled by `scale`, and a copy moved by
725    /// a known transform. The move is under half the grid spacing at the
726    /// edges; larger moves can trap point-to-point ICP on a regular grid (on
727    /// the CPU as well).
728    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            // Same answer as the CPU implementation
773            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        // The same problem at full and 1000x scale must stop at the same point
790        // and be solved just as well. An absolute translation threshold made
791        // the stopping point depend on the scene size: rounding noise in a large
792        // scene stayed above it, so ICP never stopped.
793        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}