Augmented Reality with ARKit and RealityKit

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Every note in FieldNotes can carry a NoteLocation (added on Persisting Models with SwiftData and filled in on Location and Maps). A flat map shows where notes were taken; this page shows them in space. On iPhone and iPad, FieldNotesARView stands a small pin for each nearby note on a real table, seen through the camera. On Apple Vision Pro, NotesMapSpace, the immersive space that part 1 declared as ImmersiveSpace(id: "map"), lays a 3D map of the field site on the person’s desk with the pins standing on it. The page uses the 27 releases (iOS, iPadOS, macOS, watchOS and visionOS 27), Xcode 27 and Swift 6.4, with the app’s deployment minimum of 26; the few RealityKit features that are new in 27 are guarded with if #available.

Two frameworks share the work. RealityKit renders and simulates 3D content and is the same on every platform that has it. ARKit understands the real world, such as the device’s position, planes, meshes of the room and hands, and has a different API on iOS and on visionOS. Both are shared by the two AR views, which reuse one component, NotePinComponent, and one system, NotePinSystem.

Two Frameworks, Five Platforms

Platform availability

API or feature iOS iPadOS macOS watchOS visionOS

RealityKit Entity, Component, System

Yes

Yes

Yes

Not available

Yes

RealityView

Yes (camera content)

Yes (camera content)

Yes (virtual camera only)

Not available

Yes (RealityViewContent)

content.camera = .spatialTracking, SpatialTrackingSession

Yes

Yes

Not available

Not available

SpatialTrackingSession only

ARKit ARSession, ARWorldTrackingConfiguration, ARPlaneAnchor

Yes (A12 chip or later)

Yes

Not available

Not available

Not available (use ARKitSession)

ARKit ARKitSession, PlaneDetectionProvider, SceneReconstructionProvider, HandTrackingProvider

Not available

Not available

ARKitSession and WorldTrackingProvider only (26)

Not available

Yes, in a Full Space

Reality Composer Pro 3 assets (.usda, RealityKitContent package)

Yes

Yes

Yes

Not available

Yes

LevelOfDetailComponent, navigation mesh, soft shadows

New in 27

New in 27

New in 27

Not available

New in 27

GaussianSplatComponent, cloth simulation

New in 27

New in 27

Splats only, new in 27

Not available

New in 27

RealityKit also runs on tvOS since tvOS 26, where RealityView renders with a virtual camera like on the Mac; FieldNotes does not ship a tvOS app. The watch app has no 3D view, so every file on this page is wrapped in #if canImport(RealityKit) or a narrower #if os(…​) so the shared app target still builds for watchOS.

RealityKit’s Entity-Component-System Model

RealityKit organizes a scene with the entity-component-system (ECS) pattern that game engines use:

  • an entity (Entity) is only an identity with a place in a hierarchy: a parent, children and a transform;

  • a component (a type conforming to Component) is plain data attached to an entity: a mesh and materials (ModelComponent), a collision shape (CollisionComponent), the fact that it can be tapped (InputTargetComponent), or app-defined data such as NotePinComponent;

  • a system (a type conforming to System) is behaviour: every frame it queries the scene for the entities that have certain components and updates them.

An entity has at most one component of each type. Behaviour is added by adding components, not by subclassing, so a note pin, a terrain tile and a fingertip marker are all plain Entity or ModelEntity instances with different components. Reality Composer Pro is built on the same model, so components defined in code can also be attached to entities in the editor.

Figure: Entity, Components and Systems in FieldNotes

classDiagram class Entity { <<RealityKit>> name : String transform : Transform components : ComponentSet children : ChildCollection } class Component { <<protocol>> registerComponent() } class System { <<protocol>> init(scene) update(context) registerSystem() } class NotePinComponent { noteID : UUID isPinned : Bool restHeight : Float bobPhase : Float } class ModelComponent { mesh : MeshResource materials : Material array } class CollisionComponent { shapes : ShapeResource array } class InputTargetComponent class BillboardComponent class TextComponent { text : AttributedString } class NotePinSystem { query : EntityQuery update(context) } class EntityQuery { has NotePinComponent } Component <|.. NotePinComponent Component <|.. ModelComponent Component <|.. CollisionComponent Component <|.. InputTargetComponent Component <|.. BillboardComponent Component <|.. TextComponent System <|.. NotePinSystem Entity "1" o-- "0..*" Component : one per type Entity "1" o-- "0..*" Entity : children NotePinSystem --> EntityQuery : runs every frame EntityQuery ..> Entity : matches pins NotePinSystem ..> NotePinComponent : reads and writes

NotePinComponent

The component holds everything the system needs to animate a pin, and nothing else. It is Codable so that Reality Composer Pro can show it in its inspector and save it in a scene (see Reality Composer Pro Assets).

// FieldNotes/AR/NotePinComponent.swift
#if canImport(RealityKit)
import FieldNotesModel
import RealityKit

struct NotePinComponent: Component, Codable {
    var noteID: Note.ID
    var isPinned: Bool
    var restHeight: Float = 0.06          // metres above the surface
    var bobPhase: Float = 0               // advanced by NotePinSystem
}
#endif

NotePinSystem

The system runs once per frame for the whole scene. It asks the update context for every entity with a NotePinComponent, makes each pin bob gently (pinned notes faster, so they stand out), and writes the component back. Components are value types, so the change is only stored when components.set(_:) is called.

// FieldNotes/AR/NotePinSystem.swift
#if canImport(RealityKit)
import Foundation
import RealityKit

struct NotePinSystem: System {
    private static let query = EntityQuery(where: .has(NotePinComponent.self))

    init(scene: RealityKit.Scene) {}

    func update(context: SceneUpdateContext) {
        let deltaTime = Float(context.deltaTime)
        for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) {
            guard var pin = entity.components[NotePinComponent.self] else { continue }
            pin.bobPhase += deltaTime * (pin.isPinned ? 4 : 2)
            entity.position.y = pin.restHeight + 0.008 * sin(pin.bobPhase)
            entity.components.set(pin)
        }
    }
}
#endif

RealityKit.Scene is spelled out because SwiftUI also has a Scene protocol. .rendering makes the system run only while the scene is actually drawing, which saves battery when the view is hidden. Systems are created and called by RealityKit on the main actor.

Registering them once

Custom components and systems must be registered before any scene uses them. FieldNotesApp does it in its initializer, next to the part 1 scene declarations:

// FieldNotes/FieldNotesApp.swift (excerpt)
init() {
    #if canImport(RealityKit)
    NotePinComponent.registerComponent()
    NotePinSystem.registerSystem()
    #endif
}

Building a pin entity

A small factory, shared by every platform, turns a Note into an entity: a sphere on a thin stick, a floating title that always faces the viewer, and the components that make it tappable and animated.

// FieldNotes/AR/NotePinFactory.swift
#if canImport(RealityKit)
import FieldNotesModel
import RealityKit
#if canImport(UIKit)
import UIKit       // UIColor on iOS and visionOS
#else
import AppKit      // NSColor on macOS; both have .systemOrange, .systemBlue and .gray
#endif

enum NotePinFactory {
    @MainActor
    static func makePin(for note: Note, at position: SIMD3<Float>) -> Entity {
        let pin = Entity()
        pin.name = note.id.uuidString
        pin.position = position

        let head = ModelEntity(mesh: .generateSphere(radius: 0.012),
                               materials: [SimpleMaterial(color: note.isPinned ? .systemOrange : .systemBlue,
                                                          isMetallic: false)])
        head.position.y = 0.03
        let stick = ModelEntity(mesh: .generateCylinder(height: 0.06, radius: 0.0015),
                                materials: [SimpleMaterial(color: .gray, isMetallic: true)])
        pin.addChild(stick)
        pin.addChild(head)

        let label = Entity()
        var text = TextComponent()
        text.text = AttributedString(note.title)
        text.size = CGSize(width: 240, height: 60)
        label.components.set(text)
        label.components.set(BillboardComponent())          // always faces the camera
        label.position.y = 0.06
        pin.addChild(label)

        pin.components.set(NotePinComponent(noteID: note.id, isPinned: note.isPinned))
        pin.components.set(InputTargetComponent())
        pin.components.set(CollisionComponent(shapes: [.generateSphere(radius: 0.03)]))
        return pin
    }
}
#endif

InputTargetComponent and CollisionComponent are both needed for a SwiftUI gesture to hit the entity: the first opts it into input, the second gives input a shape to hit. SimpleMaterial takes a UIColor on iOS and visionOS and an NSColor on macOS, which is why the imports are conditional.

A small layout helper converts a note’s latitude and longitude into metres on the table, relative to the first note, with an equirectangular projection that is accurate enough over a field site of a few kilometres:

// FieldNotes/AR/NotesMapLayout.swift
import FieldNotesModel
import Foundation

enum NotesMapLayout {
    /// Positions in metres, scaled so `span` metres of terrain fit in `tableSize` metres.
    static func positions(for notes: [Note], tableSize: Float = 0.6, span: Double = 2_000)
        -> [(note: Note, position: SIMD3<Float>)] {
        let located = notes.filter { $0.location != nil }
        guard let origin = located.first?.location else { return [] }
        let metresPerDegree = 111_320.0
        let scale = Double(tableSize) / span
        return located.map { note in
            let loc = note.location!
            let east = (loc.longitude - origin.longitude) * metresPerDegree
                * cos(origin.latitude * .pi / 180)
            let north = (loc.latitude - origin.latitude) * metresPerDegree
            return (note, SIMD3(Float(east * scale), 0, Float(-north * scale)))   // -z is north
        }
    }
}

NotePinFactory and NotesMapLayout are page-local helpers of the app target.

RealityView Across Platforms

RealityView is the SwiftUI view that hosts RealityKit content. Its make closure runs once, asynchronously, to build the scene; its optional update closure runs whenever SwiftUI state the closure reads changes. The content type differs by platform:

  • on iOS, iPadOS, macOS and tvOS, the closures receive a RealityViewCameraContent, which has a camera (a virtual camera, or on iOS the device camera with world tracking);

  • on visionOS, they receive a RealityViewContent: the content is placed in the person’s space, and there is no camera to set, because the person’s eyes are the camera.

On the Mac, FieldNotes shows the same map in an ordinary window, with orbit controls instead of AR. It is a compact example of the shared code:

// FieldNotes/AR/NotesMapPreview.swift (macOS window content)
#if os(macOS)
import FieldNotesServices
import RealityKit
import SwiftUI

struct NotesMapPreview: View {
    @Environment(NoteStore.self) private var store

    var body: some View {
        RealityView { content in
            content.camera = .virtual
            let root = Entity()
            for (note, position) in NotesMapLayout.positions(for: store.notes) {
                root.addChild(NotePinFactory.makePin(for: note, at: position))
            }
            content.add(root)
        }
        .realityViewCameraControls(.orbit)
    }
}

#Preview {
    NotesMapPreview()
        .environment(NoteStore(notes: [/* sample notes with locations */]))
}
#endif

NotesMapPreview is a page-local view; the Mac app opens it in a Window scene, which passes the store with .environment(store) like every other FieldNotes scene.

ARKit on iOS: FieldNotesARView

On iPhone and iPad, AR means drawing virtual content over the camera feed, locked to the real world as the device moves. ARKit’s world tracking fuses the camera image with the motion sensors to estimate the device’s position and orientation in metres, 60 times a second. On top of it, plane detection finds horizontal and vertical surfaces, and anchors pin content to a real place so it stays put as ARKit’s understanding of the room improves.

Since iOS 18, the simplest way to get all of that is from RealityView itself: set content.camera = .spatialTracking, and anchor content with an AnchorEntity whose target is a plane. RealityKit runs the ARKit session for you. SpatialTrackingSession then gives the app access to the anchors' real transforms, which FieldNotes needs to know when a table has been found:

// FieldNotes/AR/FieldNotesARView.swift
#if os(iOS)
import ARKit
import FieldNotesModel
import FieldNotesServices
import RealityKit
import SwiftUI

struct FieldNotesARView: View {
    @Environment(NoteStore.self) private var store
    @State private var trackingSession = SpatialTrackingSession()
    @State private var selectedNote: Note?
    @State private var trackingProblem: String?

    var body: some View {
        RealityView { content in
            content.camera = .spatialTracking

            // Wait for a real horizontal table at least 30 cm x 30 cm, then place the pins on it.
            let table = AnchorEntity(.plane(.horizontal, classification: .table,
                                            minimumBounds: [0.3, 0.3]),
                                     trackingMode: .once)
            for (note, position) in NotesMapLayout.positions(for: store.notes, tableSize: 0.3) {
                table.addChild(NotePinFactory.makePin(for: note, at: position))
            }
            content.add(table)
        }
        .gesture(
            SpatialTapGesture()
                .targetedToAnyEntity()
                .onEnded { value in
                    guard let pin = value.entity.components[NotePinComponent.self] else { return }
                    selectedNote = store.notes.first { $0.id == pin.noteID }
                }
        )
        .sheet(item: $selectedNote) { note in
            NoteDetail(note: note)
        }
        .overlay(alignment: .top) {
            if let trackingProblem { Text(trackingProblem).padding().background(.regularMaterial) }
        }
        .task {
            guard ARWorldTrackingConfiguration.isSupported else {
                trackingProblem = "This device doesn't support world tracking."
                return
            }
            let configuration = SpatialTrackingSession.Configuration(tracking: [.plane, .camera])
            if let unavailable = await trackingSession.run(configuration) {
                trackingProblem = "Some tracking is unavailable: \(unavailable)"
            }
        }
    }
}
#endif

// ContentView (iOS excerpt): presenting it, with the store injected explicitly.
// .fullScreenCover(isPresented: $showsARPins) {
//     FieldNotesARView().environment(store)
// }

Needs the NSCameraUsageDescription key in Info.plist and a physical iPhone or iPad with an A12 chip or later; the simulator has no camera and cannot run world tracking. Plane classification (.table) also needs a device that supports it; on others, use .any.

trackingMode: .once places the pins when the table is first found and then leaves them fixed in the world; .continuous would make them follow every refinement of the plane, which looks jittery for small content. Note is Identifiable, which is what .sheet(item:) needs; NoteDetail(note:) is part 1’s detail view.

Working with ARKit directly

SpatialTrackingSession hides ARKit’s own types. When the app needs them, for example to read plane classifications, add its own ARAnchors or turn on scene reconstruction on LiDAR devices, it can run the same session with an explicit ARSession and ARWorldTrackingConfiguration, and observe the session through its delegate:

// FieldNotes/AR/TableFinder.swift
#if os(iOS)
import ARKit
import Observation
import RealityKit

@Observable @MainActor
final class TableFinder: NSObject, ARSessionDelegate {
    private(set) var foundTable = false
    let arSession = ARSession()

    func start(with trackingSession: SpatialTrackingSession) async {
        let configuration = ARWorldTrackingConfiguration()
        configuration.planeDetection = [.horizontal, .vertical]
        if ARWorldTrackingConfiguration.supportsSceneReconstruction(.mesh) {
            configuration.sceneReconstruction = .mesh            // LiDAR devices only
        }
        arSession.delegate = self
        _ = await trackingSession.run(.init(tracking: [.plane, .camera]),
                                      session: arSession, arConfiguration: configuration)
    }

    /// Remembers where a note was placed, so ARKit keeps refining that exact spot.
    func addAnchor(for noteID: UUID, at transform: simd_float4x4) {
        arSession.add(anchor: ARAnchor(name: "note-\(noteID.uuidString)", transform: transform))
    }

    nonisolated func session(_ session: ARSession, didAdd anchors: [ARAnchor]) {
        let isTable = anchors.contains { ($0 as? ARPlaneAnchor)?.classification == .table }
        guard isTable else { return }
        Task { @MainActor in self.foundTable = true }
    }
}
#endif

The delegate callback is nonisolated because ARKit calls it on its own queue; it extracts a Bool and hops to the main actor, so no ARKit object crosses actors. The pattern is the one described on Actors, Isolation and Sendable. TableFinder is a page-local helper. The iOS-only camera, media and ARKit capture APIs outside AR views are on Camera, Media and Photos.

ARKit on visionOS: NotesMapSpace

On Apple Vision Pro there is no camera image for apps and no ARSession. Instead, an ARKitSession runs data providers, each of which delivers one kind of information as an async sequence of anchor updates:

Provider Delivers Authorization

WorldTrackingProvider

The device pose and app-placed WorldAnchors, persisted across launches

None

PlaneDetectionProvider

PlaneAnchors with alignment and classification (table, floor, wall, …​)

World sensing (NSWorldSensingUsageDescription)

SceneReconstructionProvider

MeshAnchors: a triangle mesh of the room

World sensing

HandTrackingProvider

HandAnchors with a full skeleton of joints for each hand

Hand tracking (NSHandsTrackingUsageDescription)

To protect privacy, ARKit delivers this data only while the app is in a Full Space, which means an ImmersiveSpace is open and other apps are hidden. FieldNotes’s ImmersiveSpace(id: "map") uses the default mixed immersion style, so the person still sees their room, and that already counts as a Full Space. Opening the space is shown on App Structure and Life Cycle.

NotesMapSpace reads the note store, so the scene must inject it. Part 1’s declaration gains one modifier:

// FieldNotes/FieldNotesApp.swift (visionOS excerpt, updated)
#if os(visionOS)
ImmersiveSpace(id: "map") {
    NotesMapSpace()
        .environment(store)          // NotesMapSpace reads @Environment(NoteStore.self)
}
#endif

A page-local tracker owns the session and its providers and turns their updates into entities:

// FieldNotes/AR/NotesMapTracking.swift
#if os(visionOS)
import ARKit
import Observation
import RealityKit

@Observable @MainActor
final class NotesMapTracking {
    private(set) var tableTransform: simd_float4x4?
    let meshRoot = Entity()                 // collision meshes of the room
    let fingertip = ModelEntity(mesh: .generateSphere(radius: 0.006),
                                materials: [UnlitMaterial(color: .white)])

    private let session = ARKitSession()
    private let planes = PlaneDetectionProvider(alignments: [.horizontal])
    private let meshes = SceneReconstructionProvider()
    private let hands = HandTrackingProvider()
    private var meshEntities: [UUID: Entity] = [:]

    func run() async {
        var providers: [any DataProvider] = []
        if PlaneDetectionProvider.isSupported { providers.append(planes) }
        if SceneReconstructionProvider.isSupported { providers.append(meshes) }
        if HandTrackingProvider.isSupported { providers.append(hands) }
        do {
            try await session.run(providers)          // shows the permission prompts if needed
        } catch {
            return
        }
        await withTaskGroup(of: Void.self) { group in
            group.addTask { await self.processPlanes() }
            group.addTask { await self.processMeshes() }
            group.addTask { await self.processHands() }
        }
    }

    private func processPlanes() async {
        for await update in planes.anchorUpdates where update.anchor.classification == .table {
            if update.event != .removed, tableTransform == nil {
                tableTransform = update.anchor.originFromAnchorTransform    // first table wins
            }
        }
    }

    private func processMeshes() async {
        for await update in meshes.anchorUpdates {
            let anchor = update.anchor
            switch update.event {
            case .added, .updated:
                guard let shape = try? await ShapeResource.generateStaticMesh(from: anchor) else { continue }
                let entity = meshEntities[anchor.id] ?? Entity()
                entity.components.set(CollisionComponent(shapes: [shape], isStatic: true))
                entity.setTransformMatrix(anchor.originFromAnchorTransform, relativeTo: nil)
                if meshEntities[anchor.id] == nil {
                    meshEntities[anchor.id] = entity
                    meshRoot.addChild(entity)
                }
            case .removed:
                meshEntities.removeValue(forKey: anchor.id)?.removeFromParent()
            }
        }
    }

    private func processHands() async {
        for await update in hands.anchorUpdates {
            let hand = update.anchor
            guard hand.isTracked, hand.chirality == .right,
                  let tip = hand.handSkeleton?.joint(.indexFingerTip), tip.isTracked else { continue }
            fingertip.setTransformMatrix(hand.originFromAnchorTransform * tip.anchorFromJointTransform,
                                         relativeTo: nil)
        }
    }
}
#endif

Each provider’s loop is an infinite async sequence, so the three run in parallel in a task group; the class is main-actor isolated, so each loop hops back to the main actor to touch entities. The scene-reconstruction meshes give the room collision shapes, so a pin dropped off the edge of the table falls onto the real floor instead of through it; the fingertip marker makes it easy to point at a small pin.

NotesMapSpace itself replaces part 1’s placeholder body (a green plane) with the real map. The type and the scene declaration stay the same:

// FieldNotes/Views/NotesMapSpace.swift
#if os(visionOS)
import FieldNotesModel
import FieldNotesServices
import RealityKit
import RealityKitContent
import SwiftUI

struct NotesMapSpace: View {
    @Environment(NoteStore.self) private var store
    @State private var tracking = NotesMapTracking()
    @State private var selectedNote: Note?

    var body: some View {
        RealityView { content in
            let map = Entity()
            map.name = "NotesMap"
            if let terrain = try? await Entity(named: "FieldSiteTerrain", in: realityKitContentBundle) {
                map.addChild(terrain)                                   // authored in Reality Composer Pro
            }
            for (note, position) in NotesMapLayout.positions(for: store.notes) {
                map.addChild(NotePinFactory.makePin(for: note, at: position))
            }
            map.isEnabled = false                                        // hidden until a table is found
            content.add(map)
            content.add(tracking.meshRoot)
            content.add(tracking.fingertip)
        } update: { content in
            guard let table = tracking.tableTransform,
                  let map = content.entities.first(where: { $0.name == "NotesMap" }) else { return }
            map.transform = Transform(matrix: table)
            map.isEnabled = true
        }
        .gesture(
            SpatialTapGesture()
                .targetedToAnyEntity()
                .onEnded { value in
                    guard let pin = value.entity.components[NotePinComponent.self] else { return }
                    selectedNote = store.notes.first { $0.id == pin.noteID }   // shown in a window, see below
                }
        )
        .task { await tracking.run() }
    }
}
#endif

Needs the NSWorldSensingUsageDescription and NSHandsTrackingUsageDescription keys in Info.plist and a physical Apple Vision Pro; the visionOS simulator supports none of the three providers, so isSupported is false there and the map simply stays hidden. How the selected note is shown next to the map, as a window or an attachment, belongs to the windows-volumes-and-spaces discussion on visionOS: Windows, Volumes, Immersive Spaces and RealityKit.

update runs whenever tracking.tableTransform changes, because the closure reads it and NotesMapTracking is @Observable. That is the idiomatic way to move RealityKit content in response to app state: never keep references to entities in SwiftUI state, and never mutate the scene from outside make, update, a system or a gesture handler.

To make the map reappear in the same place next time, the app can add a WorldAnchor at the table’s transform with WorldTrackingProvider.addAnchor(_:); world anchors persist across launches on the same device.

let worldTracking = WorldTrackingProvider()
// after session.run([worldTracking, ...]):
try await worldTracking.addAnchor(WorldAnchor(originFromAnchorTransform: table))

Reality Composer Pro Assets

Reality Composer Pro is Apple’s 3D content tool for RealityKit. Version 3, released alongside the 27 releases, is a separate download that needs macOS Tahoe 26.5 or later. It edits scenes as USD (.usda) files, designs materials with Shader Graph, and in version 3 adds visual scripting (Script Graph), GPU simulations (Compute Graph), animation graphs and a lightmap baker. A visionOS app template in Xcode already includes a Swift package, conventionally named RealityKitContent, whose .rkassets folder holds the Reality Composer Pro project; FieldNotes keeps it in Packages/RealityKitContent and uses it on iOS, macOS and visionOS.

Loading a scene from the package is one call. The package generates a realityKitContentBundle constant for its resource bundle:

import RealityKit
import RealityKitContent

let terrain = try await Entity(named: "FieldSiteTerrain", in: realityKitContentBundle)

Because NotePinComponent is Codable and registered, a component with the same name defined inside the RealityKitContent package appears in Reality Composer Pro’s Add Component list, so a designer can place hand-made pins in the terrain scene and the same NotePinSystem animates them. For that, move the component’s declaration into the package and make it public.

What RealityKit Adds in 27

The WWDC26 RealityKit session introduces several features, all new in 27 on iOS, iPadOS, macOS and visionOS (some on tvOS too):

  • lighting and shadows: soft shadows for dynamic lights (SpotLightComponent.Shadow.lightSize), projective textures, and lights that affect the real surroundings;

  • navigation mesh: NavigationController finds paths across a navigation mesh for characters;

  • cloth simulation: ClothBodyComponent for curtains, flags and clothing;

  • level of detail: LevelOfDetailComponent swaps meshes by camera distance or screen area;

  • 3D Gaussian splats: GaussianSplatComponent renders captured real-world scenes;

  • immersive audio: reverb meshes that model a room’s acoustics on Apple Vision Pro.

FieldNotes adopts level of detail for the terrain, because a full-resolution field site is expensive when the map is small in the view. The deployment minimum is 26, so the call is guarded:

// NotesMapSpace, inside make: three versions of the terrain from Reality Composer Pro
if #available(visionOS 27, iOS 27, macOS 27, *),
   let high = try? await Entity(named: "FieldSiteTerrain", in: realityKitContentBundle),
   let medium = try? await Entity(named: "FieldSiteTerrainMedium", in: realityKitContentBundle),
   let low = try? await Entity(named: "FieldSiteTerrainLow", in: realityKitContentBundle) {
    let terrain = Entity()
    LevelOfDetailComponent.addByCameraDistance(to: terrain, levels: [
        (entities: [high], maxDistance: 1.0),        // metres
        (entities: [medium], maxDistance: 3.0),
        (entities: [low], maxDistance: .infinity),
    ])
    map.addChild(terrain)
}

Where to Go Next

This page covers what FieldNotes draws and how it tracks the world. The visionOS app model around it, including windows, volumes, ornaments, immersion styles, shared spaces and spatial design, is on visionOS: Windows, Volumes, Immersive Spaces and RealityKit. Performance profiling of RealityKit scenes with Instruments is on Performance and Instruments. The immersive-experience design guidance is in Human Interface Guidelines — Immersive experiences.

References