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))
}
}