ARKit: Keep USDZ node fixed after image tracking is lost (prevent drifting)

0

I’m using ARKit + SceneKit (Swift) with ARWorldTrackingConfiguration and detectionImages to place a 3D object (USDZ via SCNScene(named:)) when a reference image is detected. While the image is tracked, the object stays correctly aligned.

Goal: When the tracked image is no longer visible, I want the placed node to remain visible and fixed at its last known pose (no drifting) as I move the camera.

What works so far: Detect image → add node → track updates When the image disappears → keep showing the node at its last pose

Problem: After the image is no longer tracked, the node drifts as I move the device/camera. It looks like it’s still influenced by the (now unreliable) image anchor or accumulating small world-tracking errors.

Question: What’s the correct way in ARKit to “freeze” the node at its last known world transform once ARImageAnchor stops tracking, so it doesn’t drift?

Hi @AneeshNarayanan,

I'm not 100% sure I follow what the problem is, if you could file a feedback (https://feedbackassistant.apple.com) with details and a reproduction case I can try to reproduce the issue.

That said can you switch from image to world anchor after the first detection? If I understand your question I think that will work.

Let me know if I misunderstood the question.

When the image is within the camera’s view, the node anchored to the image anchor remains stable. However, once the image goes out of view, the node starts drifting as the camera moves. What’s the correct way in ARKit to “freeze” the node at its last known world transform once ARImageAnchor stops tracking, so it doesn’t drift?

here is the code.

import UIKit import SceneKit import ARKit

class ViewController: UIViewController, ARSCNViewDelegate { @IBOutlet var sceneView: ARSCNView! var deviceNode: SCNNode?

// Persistent container driven by the image anchor (world-space)
private var anchorContainer: SCNNode?
private var orientationNode: SCNNode?   // holds your -180/0/-90 local rotation
private var debugPlaneNode: SCNNode?

var previousTransform: simd_float4x4?

override func viewDidLoad() {
    super.viewDidLoad()
    sceneView.delegate = self
    sceneView.autoenablesDefaultLighting = true
    sceneView.automaticallyUpdatesLighting = true
    sceneView.preferredFramesPerSecond = 60

    let scene = SCNScene(named: "art.scnassets/rootScene.scn")!
    sceneView.scene = scene
}

override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)
    let configuration = ARWorldTrackingConfiguration()
    configuration.detectionImages = ARReferenceImage.referenceImages(inGroupNamed: "QRImages", bundle: .main)
    configuration.maximumNumberOfTrackedImages = 1
    configuration.isLightEstimationEnabled = true
    configuration.worldAlignment = .gravity
    sceneView.session.run(configuration)
}

override func viewWillDisappear(_ animated: Bool) {
    super.viewWillDisappear(animated)
    sceneView.session.pause()
}

// MARK: - Model loading and local transform
func loadUSDZNode(from url: URL) -> SCNNode? {
    do {
        let scene = try SCNScene(url: url, options: nil)
        let containerNode = SCNNode()
        for child in scene.rootNode.childNodes {
            containerNode.addChildNode(child)
        }
        return containerNode
    } catch {
        print("❌ Failed to load .usdz at \(url): \(error)")
        return nil
    }
}

// MARK: - ARSCNViewDelegate

// We don't attach content to ARKit's managed node, return empty.
func renderer(_ renderer: SCNSceneRenderer, nodeFor anchor: ARAnchor) -> SCNNode? {
    return SCNNode()
}

// Create (once) a persistent container under root, and keep your local hierarchy inside it.
func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
    guard let imageAnchor = anchor as? ARImageAnchor else { return }

    DispatchQueue.main.async {
        if self.anchorContainer == nil {
            // World-space container driven by the image
            let container = SCNNode()
            self.sceneView.scene.rootNode.addChildNode(container)
            self.anchorContainer = container

            // Debug plane showing the detected image pose (child-local)
            let plane = SCNPlane(width: 0.125, height: 0.125)
            plane.firstMaterial?.diffuse.contents = UIColor.green
            let planeNode = SCNNode(geometry: plane)
            planeNode.eulerAngles.x = -.pi / 2
            container.addChildNode(planeNode)
            self.debugPlaneNode = planeNode

            let orient = SCNNode()
            orient.simdOrientation = self.createRotation(x: -180, y: 0, z: -90)
            container.addChildNode(orient)
            self.orientationNode = orient

            // Put your device node under the orientation node (preserves your local offset)
            if let dev = loadUSDZNode(from: url) {
                // Make sure it's not parented elsewhere
                dev.removeFromParentNode()
                orient.addChildNode(dev)
            }
        }

        // Re-anchor the container to the current image transform
        self.anchorContainer?.simdTransform = imageAnchor.transform
        self.previousTransform = imageAnchor.transform
        self.debugPlaneNode?.isHidden = false
    }
}

// Drive only the container with smoothing; keep your model’s local offset intact.
func renderer(_ renderer: SCNSceneRenderer, didUpdate node: SCNNode, for anchor: ARAnchor) {
    guard let imageAnchor = anchor as? ARImageAnchor,
          let container = anchorContainer else { return }

    if imageAnchor.isTracked {
        let currentTransform = imageAnchor.transform
        self.debugPlaneNode?.isHidden = false

        // First frame after regaining tracking → snap
        if previousTransform == nil {
            container.simdTransform = currentTransform
            previousTransform = currentTransform
            return
        }
            previousTransform = currentTransform.translation
        }
    } else {
        // Lost tracking: keep last pose, hide the plane, mark to snap on next detection
        debugPlaneNode?.isHidden = false
        previousTransform = nil
    }
}

// Keep using your degree-based helper
func createRotation(x: Float, y: Float, z: Float) -> simd_quatf {
    let xRotation = x * (.pi / 180.0)
    let yRotation = y * (.pi / 180.0)
    let zRotation = z * (.pi / 180.0)

    return simd_quatf(angle: xRotation, axis: SIMD3<Float>(1, 0, 0)) *
           simd_quatf(angle: yRotation, axis: SIMD3<Float>(0, 1, 0)) *
           simd_quatf(angle: zRotation, axis: SIMD3<Float>(0, 0, 1))
}

}

ARKit: Keep USDZ node fixed after image tracking is lost (prevent drifting)
 
 
Q