ARKit PlaneDetectionProvider: adding collisions and physics

Converting anchor geometry into collision shapes.

Overview

Let’s build on what we learned in visualize detected planes. In the previous example, we generated some meshes based on data we received from the anchors. We can use a similar process to generate ShapeResources that we can pass to Collision and Physics components.

Reminder: Apple has an example called Placing content on detected planes. That project shows this process in a bit more detail, but the logic is spread across several files, extensions, etc.

So, we have anchors and we need to convert them to ShapeResource. Make sure to read the first example if you want a walkthrough of setting up PlaneDetectionProvider.

private func createCollisionShape(anchor: PlaneAnchor) async -> ShapeResource? {
    // Generate a collision shape for the plane
    var shape: ShapeResource? = nil
    do {
        // Convert vertices to SIMD3<Float>
        let vertices = anchor.geometry.meshVertices
        var vertexArray: [SIMD3<Float>] = []
        for i in 0..<vertices.count {
            let vertex = vertices.buffer.contents().advanced(by: vertices.offset + vertices.stride * i).assumingMemoryBound(to: (Float, Float, Float).self).pointee
            vertexArray.append(SIMD3<Float>(vertex.0, vertex.1, vertex.2))
        }

        // Convert faces to UInt16
        let faces = anchor.geometry.meshFaces
        var faceArray: [UInt16] = []
        let totalFaces = faces.count * faces.primitive.indexCount
        for i in 0..<totalFaces {
            let face = faces.buffer.contents().advanced(by: i * MemoryLayout<Int32>.size).assumingMemoryBound(to: Int32.self).pointee
            faceArray.append(UInt16(face))
        }

        shape = try await ShapeResource.generateStaticMesh(positions: vertexArray, faceIndices: faceArray)
        return shape
    } catch {
        print("Failed to create a shape for collision \(error).")
    }
    return nil
}

This is very similar to createMeshResource from the other example, and we could even abstract some of the shared logic into helpers. For now, let’s consider the changes.

  • ShapeResource.generateStaticMesh is an asynchronous process
  • We convert faces to an array of UInt16 instead of UInt32. Presumably, this is an optimization for collisions.

In principle, the concepts are the same. We have vertices and faces that we need to convert to a format that we can use to generate a shape.

Now that we have the ShapeResource, let’s add some collisions and physics.

let shape = await self.createCollisionShape(anchor: anchor)
if let shape = shape {
    let collision = CollisionComponent(shapes: [shape], mode: .default)
    entity.components.set(collision)

    let physicsMaterial = PhysicsMaterialResource.generate(friction: 0, restitution: 0.8)
    let physics = PhysicsBodyComponent(shapes: [shape], mass: 0.0, material: physicsMaterial, mode: .static)
    entity.components.set(physics)
}

To test this out, we can add a sphere to the scene. We’ll make it a dynamic physics body and apply some force to it when we tap it.

// Create the subject
@State var subject : ModelEntity = {
    let subject = ModelEntity(
        mesh: .generateSphere(radius: 0.1),
        materials: [SimpleMaterial(color: .stepRed, isMetallic: false)])
    subject.setPosition([1, 1, -1], relativeTo: nil)

    let collision = CollisionComponent(shapes: [.generateSphere(radius: 0.1)])

    var physics = PhysicsBodyComponent(
        shapes: [.generateSphere(radius: 0.1)],
        mass: 1.0,
        material: .generate(friction: 0, restitution: 1),
        mode: .dynamic
    )
    physics.isAffectedByGravity = false

    let input = InputTargetComponent()
    subject.components.set([collision, physics, input])

    return subject
}()
// Lazy way to add some random force when we tap the sphere
.gesture(TapGesture()
    .targetedToEntity(subject)
    .onEnded { value in
        // Add some force when we tap the subject
        let force = SIMD3<Float>(
            x: Float.random(in: -1...1),
            y: Float.random(in: -1...1),
            z: Float.random(in: -1...1)
        )
        var motion = PhysicsMotionComponent()
        motion.linearVelocity = force * 3
        value.entity.components.set(motion)
    })

Video Demo

Example Code

This is a bit verbose, but I didn’t want to combine these two concepts yet.

struct Example070: View {
    @State var session = ARKitSession()
    @State private var planeAnchors: [UUID: Entity] = [:]
    @State private var planeColors: [UUID: Color] = [:]

    @State var subject : ModelEntity = {
        let subject = ModelEntity(
            mesh: .generateSphere(radius: 0.1),
            materials: [SimpleMaterial(color: .stepRed, isMetallic: false)])
        subject.setPosition([1, 1, -1], relativeTo: nil)

        let collision = CollisionComponent(shapes: [.generateSphere(radius: 0.1)])
        
        var physics = PhysicsBodyComponent(
            shapes: [.generateSphere(radius: 0.1)],
            mass: 1.0,
            material: .generate(friction: 0, restitution: 1),
            mode: .dynamic
        )
        physics.isAffectedByGravity = false
        
        let input = InputTargetComponent()
        subject.components.set([collision, physics, input])

        return subject
    }()

    var body: some View {
        RealityView { content in
            content.add(subject)
        } update: { content in
            for (_, entity) in planeAnchors {
                if !content.entities.contains(entity) {
                    content.add(entity)
                }
            }
        }
        .gesture(TapGesture()
            .targetedToEntity(subject)
            .onEnded { value in
                // Add some force when we tap the subject
                let force = SIMD3<Float>(
                    x: Float.random(in: -1...1),
                    y: Float.random(in: -1...1),
                    z: Float.random(in: -1...1)
                )
                var motion = PhysicsMotionComponent()
                motion.linearVelocity = force * 3
                value.entity.components.set(motion)
            })
        .task {
            try! await setupAndRunPlaneDetection()
        }
    }

    func setupAndRunPlaneDetection() async throws {
        let planeData = PlaneDetectionProvider(alignments: [.horizontal, .vertical, .slanted])
        if PlaneDetectionProvider.isSupported {
            do {
                try await session.run([planeData])
                for await update in planeData.anchorUpdates {
                    switch update.event {
                    case .added, .updated:
                        let anchor = update.anchor
                        if planeColors[anchor.id] == nil {
                            planeColors[anchor.id] = generatePastelColor()
                        }
                        let planeEntity = createPlaneEntity(for: anchor, color: planeColors[anchor.id]!)
                        planeAnchors[anchor.id] = planeEntity
                    case .removed:
                        let anchor = update.anchor
                        if let entity = planeAnchors[anchor.id] {
                            entity.removeFromParent()
                            planeAnchors.removeValue(forKey: anchor.id)
                            planeColors.removeValue(forKey: anchor.id)
                        }
                    }
                }
            } catch {
                print("ARKit session error \(error)")
            }
        }
    }

    private func generatePastelColor() -> Color {
        let hue = Double.random(in: 0...1)
        let saturation = Double.random(in: 0.2...0.4)
        let brightness = Double.random(in: 0.8...1.0)
        return Color(hue: hue, saturation: saturation, brightness: brightness)
    }

    private func createPlaneEntity(for anchor: PlaneAnchor, color: Color) -> Entity {
        let entity = Entity()
        entity.name = "Plane \(anchor.id)"
        entity.setTransformMatrix(anchor.originFromAnchorTransform, relativeTo: nil)

        var material = PhysicallyBasedMaterial()
        material.baseColor.tint = UIColor(color)

        if let meshResource = createMeshResource(anchor: anchor) {
            entity.components.set(ModelComponent(mesh: meshResource, materials: [material]))
        }

        Task {
            let shape = await self.createCollisionShape(anchor: anchor)
            if let shape = shape {
                let collision = CollisionComponent(shapes: [shape], mode: .default)
                entity.components.set(collision)

                let physicsMaterial = PhysicsMaterialResource.generate(friction: 0, restitution: 0.8)
                let physics = PhysicsBodyComponent(shapes: [shape], mass: 0.0, material: physicsMaterial, mode: .static)
                entity.components.set(physics)
            }
        }

        return entity
    }

    private func createMeshResource(anchor: PlaneAnchor) -> MeshResource? {
        // Generate a mesh for the plane (for occlusion).
        var meshResource: MeshResource? = nil
        do {
            var contents = MeshResource.Contents()
            contents.instances = [MeshResource.Instance(id: "main", model: "model")]
            var part = MeshResource.Part(id: "part", materialIndex: 0)

            // Convert vertices to SIMD3<Float>
            let vertices = anchor.geometry.meshVertices
            var vertexArray: [SIMD3<Float>] = []
            for i in 0..<vertices.count {
                let vertex = vertices.buffer.contents().advanced(by: vertices.offset + vertices.stride * i).assumingMemoryBound(to: (Float, Float, Float).self).pointee
                vertexArray.append(SIMD3<Float>(vertex.0, vertex.1, vertex.2))
            }
            part.positions = MeshBuffers.Positions(vertexArray)

            // Convert faces to UInt32
            let faces = anchor.geometry.meshFaces
            var faceArray: [UInt32] = []
            let totalFaces = faces.count * faces.primitive.indexCount
            for i in 0..<totalFaces {
                let face = faces.buffer.contents().advanced(by: i * MemoryLayout<Int32>.size).assumingMemoryBound(to: Int32.self).pointee
                faceArray.append(UInt32(face))
            }
            part.triangleIndices = MeshBuffer(faceArray)

            contents.models = [MeshResource.Model(id: "model", parts: [part])]
            meshResource = try MeshResource.generate(from: contents)
            return meshResource
        } catch {
            print("Failed to create a mesh resource for a plane anchor: \(error).")
        }
        return nil
    }

    private func createCollisionShape(anchor: PlaneAnchor) async -> ShapeResource? {
        // Generate a collision shape for the plane
        var shape: ShapeResource? = nil
        do {
            // Convert vertices to SIMD3<Float>
            let vertices = anchor.geometry.meshVertices
            var vertexArray: [SIMD3<Float>] = []
            for i in 0..<vertices.count {
                let vertex = vertices.buffer.contents().advanced(by: vertices.offset + vertices.stride * i).assumingMemoryBound(to: (Float, Float, Float).self).pointee
                vertexArray.append(SIMD3<Float>(vertex.0, vertex.1, vertex.2))
            }

            // Convert faces to UInt16
            let faces = anchor.geometry.meshFaces
            var faceArray: [UInt16] = []
            let totalFaces = faces.count * faces.primitive.indexCount
            for i in 0..<totalFaces {
                let face = faces.buffer.contents().advanced(by: i * MemoryLayout<Int32>.size).assumingMemoryBound(to: Int32.self).pointee
                faceArray.append(UInt16(face))
            }

            shape = try await ShapeResource.generateStaticMesh(positions: vertexArray, faceIndices: faceArray)
            return shape
        } catch {
            print("Failed to create a shape for collision \(error).")
        }
        return nil
    }
}

Support our work so we can continue to bring you new examples and articles.

Download the Xcode project with this and many more examples from Step Into Vision.
Some examples are provided as standalone Xcode projects. You can find those here.

Questions or feedback?