RealityKit Basics: Using Spatial

Spatial is a collection of types for working with 3D mathematical primitives.

Overview

In addition to RealityKit, Apple provides Spatial. This framework contains a collection of useful constructs for working in 3D. These can be handy when building features that need to do complex 3D math or when clarity is important. Some examples

  • Vector3D provides many common vector math helpers such as dot product, cross product, length, normal, etc.
  • Point3D represents a point (position) in 3D space
  • Size3D represents a size in 3D space
  • Angle2D and Axiz3D are used to construct rotations

By now it is likely you have run into these when working with RealityKit or SwiftUI. For example, when constructing a transform.

AffineTransform3D(
    scale: Size3D(vector: [0.5, 0.5, 0.5]),
    rotation: Rotation3D(angle: Angle2D(degrees: 20), axis: .x),
    translation: Point3D(x: 25, y: 25, z: 50)
)

We’re not going to cover everything this framework has to offer. Our main goal is to make you aware of the tools that are available to you. So when do we use Spatial?

  • When doing complex 3D math, check Spatial to see if the features already exist.
  • When we need to provide clarity. For example, instead of using SIMD3 for everything, we can use Point3D to a represent position and Vector3D to present a direction.

When using these types, we often need to convert back to a format compatible with RealityKit. We can’t set an entity position to an Point3D, but we can do something like this:

var point = Point3D(x: 10.0, y: 1.0, z: 0.0)
// complext math to work with the point
entity.position = SIMD3<Float>(point)

Note: we don’t actually need to import Spatial in most cases. It seems to be included when we import RealityKit.

Video Demo

Example Code

struct Example140: View {

    @State private var target: ModelEntity = {
        let material = SimpleMaterial(color: .orange, isMetallic: false)
        let entity = ModelEntity(
            mesh: .generateSphere(radius: 0.03),
            materials: [material]
        )
        entity.position = [0.25, 0.1, -0.2]
        ManipulationComponent.configureEntity(entity)
        return entity
    }()

    @State private var subject: Entity = {

        let entity = createStepDemoBox()
        entity.position = [-0.25, 0.1, -0.2]
        entity.scale = .init(repeating: 0.5)

        return entity
    }()

    var body: some View {
        RealityView { content in
            content.add(subject)
            content.add(target)
            faceSubjectTowardTarget()

            _ = content.subscribe(to: ManipulationEvents.DidUpdateTransform.self) { event in
                faceSubjectTowardTarget()
            }
        }
        .debugBorder3D(.white)
        .toolbar {
            ToolbarItem(placement: .bottomOrnament) {
                VStack {
                    HStack {
                        Button(action: {
                            let p = randomPointInVolume(0.4)
                            subject.position = SIMD3<Float>(p)
                            faceSubjectTowardTarget()
                        }, label: {
                            Text("Move Subject")
                        })

                        Button(action: {
                            let p = randomPointInVolume(0.45)
                            target.position = SIMD3<Float>(p)
                            faceSubjectTowardTarget()
                        }, label: {
                            Text("Move Target")
                        })

                        Button(action: {
                            faceSubjectTowardTarget()
                        }, label: {
                            Text("Face Target")
                        })
                    }
                }
                .controlSize(.small)
            }
        }
    }

    // These helper functions are **intentionally contrived** to show off the Math features of Spaital.

    // There are easier ways to do these things
    private func randomPointInVolume(_ range: Double = 0.5) -> Point3D {
        // Build a random direction vector using angles, then normalize it (Spatial math).
        let yaw = Angle2D.radians(Double.random(in: 0 ... (2 * .pi)))
        let pitch = Angle2D.radians(Double.random(in: (-.pi / 6) ... (.pi / 6))) // mostly horizontal

        let cy = cos(yaw.radians)
        let sy = sin(yaw.radians)
        let cp = cos(pitch.radians)
        let sp = sin(pitch.radians)

        // Forward-biased direction (negative Z is "forward" in RealityKit by convention)
        var dir = Vector3D(x: sy * cp, y: sp, z: -cy * cp)
        let len = dir.length
        guard len > 0.000001 else { return .zero }
        dir /= len

        // Pick a distance from the origin
        let distance = Double.random(in: 0.15 ... range)

        // Convert vector -> point and keep it inside a simple cube bound.
        var p = Point3D(x: dir.x * distance, y: dir.y * distance, z: dir.z * distance)
        p.x = max(-range, min(range, p.x))
        p.y = max(-range, min(range, p.y))
        p.z = max(-range, min(range, p.z))

        return p
    }

    // Building a helper function that will update cause the subject to face the target
    private func faceSubjectTowardTarget() {
        // Read positions from RealityKit
        let s = subject.position
        let t = target.position

        // Do the math in Spatial (Double-based)
        let subjectPos = Vector3D(x: Double(s.x), y: Double(s.y), z: Double(s.z))
        let targetPos  = Vector3D(x: Double(t.x), y: Double(t.y), z: Double(t.z))

        // Direction from subject -> target
        var dir = targetPos - subjectPos
        let dirLen = dir.length
        guard dirLen > 0.0001 else { return }
        dir /= dirLen

        // RealityKit's "forward" is typically -Z in local space
        let forward = Vector3D(x: 0, y: 0, z: -1)

        // Rotation that takes `forward` to `dir`
        let d = max(-1.0, min(1.0, forward.dot(dir)))
        var axis = forward.cross(dir)

        // If forward and dir are (anti)parallel, cross is near-zero.
        if axis.length < 0.000001 {
            // If pointing the same way, no rotation. If opposite, rotate 180° around Y.
            if d > 0.999999 { return }
            axis = Vector3D(x: 0, y: 1, z: 0)
        } else {
            axis /= axis.length
        }

        let angleRadians = acos(d)

        // Convert back to RealityKit
        let axisF = SIMD3<Float>(Float(axis.x), Float(axis.y), Float(axis.z))
        subject.orientation = simd_quatf(angle: Float(angleRadians), axis: axisF)
    }
}

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?