Every aspect of the LiDAR Scanner Xcode project โ from how laser depth sensing physically works, to the Swift code that captures meshes and exports 3D files. iPad Pro M4 ยท ARKit ยท RealityKit ยท ModelIO ยท SwiftUI.
This guide walks through the complete LiDAR scanning pipeline โ from the physics of time-of-flight sensing, to ARKit's scene reconstruction pipeline, to the Swift code that converts raw GPU mesh buffers into exportable 3D files in USDZ, OBJ, and PLY formats.
Read front to back for a full understanding, or jump directly to any chapter. The code walkthrough in Chapters 5โ7 is self-contained if you already understand the theory.
LiDAR stands for Light Detection and Ranging. The sensor fires invisible infrared laser pulses at a scene and measures the exact time each pulse takes to bounce back. Because light travels at a known speed (~300,000 km/s), the sensor calculates the distance to every point it hits.
Distance = (Speed of Light ร Round-trip Time) รท 2
The iPad Pro uses direct ToF โ each laser pulse gets its own timer. This gives accurate depth even in motion.
Each pulse โ one 3D point (X, Y, Z). Thousands of pulses per second โ a dense point cloud. ARKit fuses these into a continuous mesh surface automatically.
| Property | Detail |
|---|---|
| Range | Up to ~5 metres (indoor), best under 2m for objects |
| Pulse rate | Thousands of depth points per second |
| Latency | Low latency โ ARKit can reconstruct in real time |
| Works in dark? | Yes โ laser is infrared, does not need visible light |
| Works outside? | Yes, but sunlight IR interference reduces accuracy |
| Vs camera depth | Far more accurate than stereo camera depth estimation |
ARKit runs a multi-stage pipeline to turn raw LiDAR depth data into the clean 3D mesh your app receives:
Each ARMeshAnchor represents a region of the reconstructed mesh. It contains:
geometry.vertices โ buffer of Float3 positions (X, Y, Z in metres, local space)geometry.faces โ buffer of triangle indices (3 ร UInt32 per face)geometry.normals โ surface normal vectors per vertexgeometry.classification โ per-face semantic label (floor, wall, etc.)transform โ 4ร4 matrix to convert local โ world coordinates| Requirement | Detail |
|---|---|
| Mac | macOS 14 Sonoma or later recommended |
| Xcode | Version 15 or later (free from Mac App Store) |
| Apple ID | Free account works โ 7-day sideload cert. Paid $99/yr = no expiry |
| iPad Pro | Any iPad Pro with LiDAR (2020+). M4 is perfect |
| USB Cable | USB-C to USB-C, or USB-C to Mac port adapter |
| iOS | 17.0 minimum on the iPad |
com.YOURNAME.lidarscanner.| File | Responsibility |
|---|---|
LiDARScannerApp.swift | Entry point. @main struct that launches the SwiftUI window. |
ContentView.swift | All visible UI. Buttons, stats, share sheet trigger. Reads from ScanManager. |
ARViewContainer.swift | Tiny bridge between SwiftUI and UIKit/RealityKit. Wraps ARView. |
ScanManager.swift | The brain. Runs AR session, collects mesh anchors, exports 3D files. |
ShareSheet.swift | Wraps UIActivityViewController so SwiftUI can call the iOS share sheet. |
Info.plist | Camera permission text. LiDAR device capability requirement. |
The simplest file. Swift's @main attribute marks this as the application entry point. SwiftUI's App protocol launches a WindowGroup containing ContentView.
import SwiftUI
@main
struct LiDARScannerApp: App {
var body: some Scene {
WindowGroup {
ContentView() // Root view
}
}
}Swift
RealityKit's ARView is a UIKit component (UIView). SwiftUI cannot use UIKit views directly โ you need a UIViewRepresentable wrapper. This struct is that bridge.
struct ARViewContainer: UIViewRepresentable {
@ObservedObject var scanManager: ScanManager
// Called ONCE: create the ARView
func makeUIView(context: Context) -> ARView {
let arView = ARView(frame: .zero)
scanManager.setup(arView: arView) // hand off to ScanManager
return arView
}
// Called on SwiftUI re-renders: nothing to update
func updateUIView(_ uiView: ARView, context: Context) {}
}Swift
ScanManager inherits from NSObject (required by ARSessionDelegate), ObservableObject (so SwiftUI can react to changes), and ARSessionDelegate (to receive mesh updates from ARKit).
@MainActor
class ScanManager: NSObject, ObservableObject, ARSessionDelegate {
// Published = SwiftUI watches these automatically
@Published var meshAnchorCount: Int = 0
@Published var totalVertexCount: Int = 0
@Published var totalFaceCount: Int = 0
@Published var statusMessage: String = "Move iPad to start scanning"
@Published var showMesh: Bool = true
// Internal storage: one entry per mesh region
private var meshAnchors: [UUID: ARMeshAnchor] = [:]
private weak var arView: ARView?
}Swift
func setup(arView: ARView) {
self.arView = arView
arView.session.delegate = self
guard ARWorldTrackingConfiguration
.supportsSceneReconstruction(.meshWithClassification) else {
statusMessage = "LiDAR not available"
return
}
startSession()
}
private func startSession() {
let config = ARWorldTrackingConfiguration()
config.sceneReconstruction = .meshWithClassification
config.environmentTexturing = .automatic
config.planeDetection = [.horizontal, .vertical]
arView?.session.run(config, options: [.resetTracking, .removeExistingAnchors])
arView?.debugOptions = showMesh ? [.showSceneUnderstanding] : []
}Swift
nonisolated func session(_ session: ARSession, didAdd anchors: [ARAnchor]) {
let meshes = anchors.compactMap { $0 as? ARMeshAnchor }
guard !meshes.isEmpty else { return }
Task { @MainActor in self.addMeshAnchors(meshes) }
}
nonisolated func session(_ session: ARSession, didUpdate anchors: [ARAnchor]) {
let meshes = anchors.compactMap { $0 as? ARMeshAnchor }
guard !meshes.isEmpty else { return }
Task { @MainActor in self.updateMeshAnchors(meshes) }
}
nonisolated func session(_ session: ARSession, didRemove anchors: [ARAnchor]) {
let ids = anchors.compactMap { $0 as? ARMeshAnchor }.map { $0.identifier }
guard !ids.isEmpty else { return }
Task { @MainActor in self.removeMeshAnchors(ids) }
}Swift
| Type | Role |
|---|---|
ARMeshGeometry | Raw GPU buffers โ vertices and face indices as Metal MTLBuffer |
MDLMesh | ModelIO mesh object. Understands vertex descriptors, materials, transforms |
MDLAsset | Container for one or more MDLMesh objects. Has the export() method |
MTKMeshBufferAllocator | Bridges between Metal GPU buffers and ModelIO CPU buffers |
| Format | Description |
|---|---|
| USDZ | Apple's preferred format. Self-contained ZIP of USD + textures. Opens natively in iOS Files, QuickLook, AR Quick Look. Best for sharing on Apple devices. |
| OBJ | Universal 3D format. Opens in Blender, Maya, Cinema4D, 3ds Max. Geometry only โ no texture in our export. Good for further editing. |
| PLY | Polygon file format. Widely used in point cloud research. Opens in MeshLab, CloudCompare, Open3D. Good for scientific/analysis workflows. |
The UI is a ZStack โ a layered stack. ARViewContainer fills the background (full screen camera feed + mesh), and all controls float on top as overlays.
ZStack {
ARViewContainer(scanManager: scanManager)
.ignoresSafeArea()
VStack {
// Top bar: title, status, mesh toggle button
HStack { ... }.background(gradient: black โ clear)
Spacer()
// Middle: vertex/face stats badges
HStack { StatBadge(...) ... }
// Bottom: Reset + Export buttons
HStack { ... }.background(gradient: clear โ black)
}
}Swift
ContentView creates ScanManager as a @StateObject. Any change to its @Published properties automatically re-renders the relevant parts of the UI.
@StateObject private var scanManager = ScanManager()
// These auto-update the StatBadges:
scanManager.meshAnchorCount // โ "Anchors: 12"
scanManager.vertexCountFormatted // โ "Vertices: 48.2K"
scanManager.faceCountFormatted // โ "Faces: 31.7K"
scanManager.statusMessage // โ "Scanning... 12 mesh regions"Swift
| Issue | Effect |
|---|---|
| Small objects (<20cm) | Very low detail. Figurines, mugs look blocky. Use Polycam instead. |
| Shiny/reflective surfaces | LiDAR IR pulses scatter โ gaps and holes in mesh |
| Glass/transparent objects | Near-invisible to LiDAR โ large holes guaranteed |
| Fine detail (hair, fabric) | Smoothed out by the marching-cubes mesh extraction |
| Texture / color | Raw export has no texture. You need to project camera image onto mesh separately |
| Underside of objects | Anything the camera cannot see = missing geometry |
The biggest missing feature: color on the exported mesh. Capture ARFrame.capturedImage alongside the depth, project camera pixels onto mesh triangles using the camera's intrinsic matrix, and bake a texture atlas into the OBJ export as an MTL material file. Apple's Object Capture API (on macOS) can do this automatically from a video scan.
Raw ARKit meshes can be very dense. Adding a decimation step would shrink file sizes massively. ModelIO has built-in support via generateAmbientOcclusionTexture, or use a third-party lib like MeshOptimizer via Swift Package Manager.
Use ARKit's mesh classification to filter and keep only specific surface types during export:
// Filter to only table-top objects during export
let classification = anchor.geometry.classificationOf(face: faceIndex)
if classification == .table || classification == .none {
// include this face
}Swift