RealityView AR - anchored to the screen not the floor

This started out as a plea for help, but in preparing this post I discovered the root cause. I'm posting it as a lesson learned in hopes it will help someone.

I've spent a good chunk of March trying to get AR-mode working again in my unreleased game. I had it working with SceneKit and ARView 5 years ago, but since 2024 I've been converting the game to use RealityKit and RealityView on iOS, macOS, visionOS, and tvOS. I've been having no joy getting AR mode to work on iOS. I get the pass-through device video but the game content isn't anchored to the floor but rather anchored to the screen.

I made a simple project with just a simple shape in the middle of a RealityView and an overlay with a SwiftUI toggle to go in and out of AR-mode. At first, my simple project worked, and I couldn't figure out what was different in the logic. Both projects used the same logic:

func transitionToXR(_ content: inout RealityViewCameraContent) {
    content.remove(gameBoard.rootEntity)
    content.add(xrAnchor)
    content.camera = .spatialTracking
    
    Self.anchorStateChangedSubscription = content.subscribe(to: SceneEvents.AnchoredStateChanged.self)  { event in
        if event.anchor == xrAnchor, event.isAnchored {
            xrAnchor.addChild(gameBoard.rootEntity)
        }
    }
}

Then I made an alternate version of my view, and reproduced the same "anchored to the screen not the floor" issue.

I compared the code side-by-side and finally saw the difference! The one that didn't work, like my game, had a property 'cameraEntity' which is initialized with PerspectiveCamera(), position and look-at configured, then added as a child of the root entity. So, the simple fix was to remove 'cameraEntity' from the root entity before adding it to the detected AnchorEntity when going into AR-mode. Then when leaving AR-mode, I add back 'cameraEntity' as a child of the root entity and configure it again.

So the lesson learned is: make sure there isn't a PerspectiveCamera in the tree of Entities added to an AnchorEntity with a .spatialTracking content camera.

Apple: let me know if you think this is a bug or if I was being dumb. If a bug, I can use Feedback Assistant to report this. If I was being dumb, it wouldn't be the first time. :-)

Answered by DTS Engineer in 884970022

Thanks for the excellent write-up and for sharing your findings — this will definitely help others.

We were able to reproduce the issue using a minimal project that mirrors your setup: a PerspectiveCamera added as a child of the root entity, then transitioning to AR with .spatialTracking and an AnchoredStateChanged subscription. With the camera in the tree, the content floats with the device instead of anchoring to the floor. Removing the PerspectiveCamera before entering AR mode resolves it, just as you described.

The interaction between a PerspectiveCamera entity in the hierarchy and content.camera = .spatialTracking isn't documented — the API doesn't prohibit having both, but it also doesn't describe what should happen when they coexist. At minimum, the framework should produce a diagnostic warning when it detects the conflict rather than silently falling back to screen-anchored behavior.

Please file a feedback report through Feedback Assistant with your test project and post the feedback number here. Your workaround — removing the PerspectiveCamera before entering AR mode and restoring it when leaving — is the right approach in the meantime.

Accepted Answer

Thanks for the excellent write-up and for sharing your findings — this will definitely help others.

We were able to reproduce the issue using a minimal project that mirrors your setup: a PerspectiveCamera added as a child of the root entity, then transitioning to AR with .spatialTracking and an AnchoredStateChanged subscription. With the camera in the tree, the content floats with the device instead of anchoring to the floor. Removing the PerspectiveCamera before entering AR mode resolves it, just as you described.

The interaction between a PerspectiveCamera entity in the hierarchy and content.camera = .spatialTracking isn't documented — the API doesn't prohibit having both, but it also doesn't describe what should happen when they coexist. At minimum, the framework should produce a diagnostic warning when it detects the conflict rather than silently falling back to screen-anchored behavior.

Please file a feedback report through Feedback Assistant with your test project and post the feedback number here. Your workaround — removing the PerspectiveCamera before entering AR mode and restoring it when leaving — is the right approach in the meantime.

Sorry for my slow response. I just created FB22780449 with the source code of my simple test project. Please see my comments in that post about the content sometimes not appearing where expected when returning from AR, and sometimes not finding an anchor when going in&out&in&out&in&out&in&out of AR like a squeezebox. (c;

FYI:

//
//  ContentView.swift
//  XRSandbox
//
//  Created by Andy Wyatt on 3/15/26.
//

import SwiftUI
import RealityKit

struct ContentView: View {
    private let rootEntity = Entity()
    
    var body: some View {
        RealityView { content in
            content.add(rootEntity)
        }
        update: { content in
            updateARState(&content)
        }
        .ignoresSafeArea()
        .onAppear() {
            configureVirtualCamera()
            rootEntity.addChild(makeThing())
        }
        .onTapGesture {
            spinThing()
        }
        .overlay {
            overlayView
        }
    }
    
    //MARK: - overlay with toggles at bottom
    
    @State private var arToggleState: Bool = false
    @State private var showARView: Bool = false
    @State private var perspectiveCameraInAR: Bool = false

    private var overlayView: some View {
        VStack {
            Spacer()
            VStack {
                Toggle("Enable AR", isOn: $arToggleState)
                    .onChange(of: arToggleState) {
                        handleARToggle()
                    }
                // If PerspectiveCamera is in the RealityView entity heirarchy
                // the content is anchored to the screen rather than the floor
                Toggle("Keep PerspectiveCamera in AR", isOn: $perspectiveCameraInAR)
                    .disabled(arToggleState) // can only change when not in AR
            }
            .padding()
            .background(Color(uiColor: UIColor.systemBackground).opacity(0.5)) // invisible when not in AR
            .cornerRadius(10)
        }
        .padding()
    }
    private func handleARToggle() {
        if arToggleState, ARUserAuth.isAvailableAndAuthorizedByUser == false {
            arToggleState = false
            return
        }
        if showARView != arToggleState {
            showARView = arToggleState
        }
    }
    
    //MARK: - transition to/from AR
    
    private let xrAnchor = AnchorEntity(.plane(.horizontal, classification: .any, minimumBounds: [0.2, 0.2]))

    static private var anchorStateChangedSubscription: EventSubscription? = nil
    static private var inSpatialTrackingCameraMode: Bool = false

    private func updateARState(_ content: inout RealityViewCameraContent) {
        if showARView == true && Self.inSpatialTrackingCameraMode == false {
            Self.inSpatialTrackingCameraMode = true
            transitionToAR(&content)
        }
        else if showARView == false && Self.inSpatialTrackingCameraMode == true {
            Self.inSpatialTrackingCameraMode = false
            transitionFromAR(&content)
        }
    }
    
    //TODO: not always getting anchor event after several in/out/in/out of AR cycles - why?
    // experiment with sequence of events here and in transitionFromAR()
    // maybe more reliable with a custom spatial tracking session?
    private func transitionToAR(_ content: inout RealityViewCameraContent) {
        content.remove(rootEntity)
        content.add(xrAnchor)
        
        content.camera = .spatialTracking
        print("spatialTracking camera activated: \(content.camera) •••••••••••••")

        Self.anchorStateChangedSubscription = content.subscribe(to: SceneEvents.AnchoredStateChanged.self)  { event in
            print("••• anchor changed ••• anchor: '\(event.anchor.name)', isAnchored: \(event.isAnchored), anchor transform: \(String(describing: event.anchor.convert(transform: event.anchor.transform, to: nil))) •••••••••••••")
            if event.anchor == xrAnchor, event.isAnchored {
                if perspectiveCameraInAR == false {
                    // If PerspectiveCamera is in the RealityView entity heirarchy
                    // the content is anchored to the screen rather than the floor
                    rootEntity.removeChild(cameraEntity)
                }
                rootEntity.position.y = 1.0 //1m above the anchor
                xrAnchor.addChild(rootEntity)
            }
        }
    }
    private func transitionFromAR(_ content: inout RealityViewCameraContent) {
        Self.anchorStateChangedSubscription?.cancel()
        Self.anchorStateChangedSubscription = nil
        content.remove(xrAnchor)
        
        content.camera = .virtual
        print("virtual camera activated: \(content.camera) •••••••••••••")
        configureVirtualCamera() //adds back to scene if removed in transitionToXR(_:)
        
        rootEntity.removeFromParent() // remove from xrAnchor
        rootEntity.position = .zero
        content.add(rootEntity)
    }
    
    //MARK: - virtual camera
    
    private let cameraEntity = PerspectiveCamera()
    
    private func configureVirtualCamera() {
        rootEntity.addChild(cameraEntity)
        cameraEntity.name = "virtual camera"
        cameraEntity.position = [0, 0, 2] // matches the default virtual camera
        cameraEntity.look(at: .zero, from: cameraEntity.position, relativeTo: nil)
    }
    
    //MARK: - thing
    
    func makeThing() -> Entity {
        let thing = ModelEntity(mesh: .generateCylinder(height: 0.5, radius: 0.2),
                                materials: [SimpleMaterial(color: .purple, isMetallic: true)])
        thing.transform.rotation = .init(angle: 45.0, axis: [0,1,1])
        thing.name = "thing"
        return thing
    }
    func spinThing() {
        guard let thing = rootEntity.findEntity(named: "thing") else { return }
        let spin = SpinAction(revolutions: 1, localAxis: [0, 1, 1], timingFunction: .easeInOut)
        let spinAnimation = try! AnimationResource.makeActionAnimation(for: spin,
                                                                       duration: 2.0,
                                                                       bindTarget: .transform,
                                                                       repeatMode: .none)
        thing.playAnimation(spinAnimation)
    }
}

#Preview {
    ContentView()
}
//
//  XRSandboxApp.swift
//  XRSandbox
//
//  Created by Andy Wyatt on 3/6/26.
//

import SwiftUI

@main
struct XRSandboxApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}
//
//  ARUserAuth.swift
//  XRSandbox
//
//  Created by Andy Wyatt on 3/15/26.
//

import SwiftUI
import RealityKit
import ARKit
import AVFoundation
import CoreMotion

public struct ARUserAuth {
    public static var isAvailableAndAuthorizedByUser: Bool {
        guard ARWorldTrackingConfiguration.isSupported else { return false }
        return hasCameraAuthorization && hasMotionAuthorization
    }
    public static var hasCameraAuthorization: Bool {
        let authStatus = AVCaptureDevice.authorizationStatus(for: .video)
        if authStatus == .notDetermined {
            requestUserAuthorizationForCamera()
        }
        return authStatus == .authorized
    }
    public static func requestUserAuthorizationForCamera() {
        print("ARUserAuth: Camera access not determined, requesting user authorization")
        AVCaptureDevice.requestAccess(for: .video) { granted in
            if granted {
                print("ARUserAuth: App access to Camera authorized by user")
                // since these are modal popups, request Motion & Fitness only after Camera access granted
                if coreMotionAuthorizationStatus == .notDetermined {
                    print("ARUserAuth: Motion & Fitness access not determined, requesting user authorization")
                    self.requestUserAuthorizationForMotion()
                }
            } else {
                print("ARUserAuth: App access to Camera denied by user")
            }
        }
    }
    public static var coreMotionAuthorizationStatus: CMAuthorizationStatus {
        //TODO: is this the best way to get this?
        return CMPedometer.authorizationStatus()
    }
    public static var hasMotionAuthorization: Bool {
        let authStatus = coreMotionAuthorizationStatus
        return authStatus == .authorized
    }
    public static func requestUserAuthorizationForMotion() {
        CMMotionActivityManager().queryActivityStarting(from: .now, to: .now, to: .main) { activity, error in
            // shows the permission prompt if no authorization
            print("ARUserAuth: App access to Motion & Fitness authorized by user")
        }
    }
}
RealityView AR - anchored to the screen not the floor
 
 
Q