+1 (415) 993-7206

Migrating an ARSCNView App to RealityKit: A Practical Walkthrough

This is the hands-on companion to our SceneKit → RealityKit migration service page. Apple deprecated SceneKit at WWDC 2025 (critical-bug-only maintenance from here on, see Bring your SceneKit project to RealityKit). If your AR app is built on ARSCNView, this walkthrough shows the before-and-after for each part of a typical app, in the order we usually migrate them.

We assume an iOS 18+ deployment target and Xcode 16 or later. Where an API only exists on a newer OS we say so.

The mental model shift

SceneKit is a scene graph: nodes own geometry, materials, lights, and children, and you mutate them directly. RealityKit is an entity-component system: an Entity is mostly a transform plus a bag of components (ModelComponent, CollisionComponent, PhysicsBodyComponent, and so on), and behavior lives in systems that run over components each frame.

The practical consequence is that most of your SCNNode subclasses become plain Entity instances plus a custom Component holding their data. Resist the urge to subclass Entity to reproduce the old class hierarchy; it works, but it fights the framework.

1. The view: ARSCNView → RealityView

Before

final class ARViewController: UIViewController, ARSCNViewDelegate {
    let sceneView = ARSCNView()

    override func viewDidLoad() {
        super.viewDidLoad()
        view.addSubview(sceneView)
        sceneView.frame = view.bounds
        sceneView.delegate = self
        sceneView.scene = SCNScene()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        let config = ARWorldTrackingConfiguration()
        config.planeDetection = [.horizontal]
        sceneView.session.run(config)
    }
}

After

import SwiftUI
import RealityKit

struct ARContentView: View {
    var body: some View {
        RealityView { content in
            content.camera = .spatialTracking
            // Build entities here.
        }
        .ignoresSafeArea()
    }
}

If your app is UIKit-only and you cannot host SwiftUI yet, ARView (RealityKit's UIKit view) is the intermediate step: it accepts an ARSession and ARWorldTrackingConfiguration exactly like ARSCNView. But RealityView is where Apple is investing and it is what runs on visionOS, so we recommend going straight there if the app can host a UIHostingController.

2. Plane anchors: delegate callbacks → AnchorEntity

Before

func renderer(_ renderer: SCNSceneRenderer, didAdd node: SCNNode, for anchor: ARAnchor) {
    guard let planeAnchor = anchor as? ARPlaneAnchor else { return }
    let plane = SCNPlane(width: CGFloat(planeAnchor.planeExtent.width),
                         height: CGFloat(planeAnchor.planeExtent.height))
    let planeNode = SCNNode(geometry: plane)
    planeNode.eulerAngles.x = -.pi / 2
    node.addChildNode(planeNode)
}

After

let tableAnchor = AnchorEntity(.plane(.horizontal, classification: .table, minimumBounds: [0.3, 0.3]))
content.add(tableAnchor)

RealityKit resolves the anchor for you; children of tableAnchor appear once a matching plane exists. You lose the per-anchor geometry callback, which is usually a relief — most apps only used it to draw a debug plane. If you genuinely need plane extents (for a floor-filling effect, for example), run your own ARSession alongside and read ARPlaneAnchor updates from its delegate; ARView exposes session directly.

3. Geometry and materials

Before

let box = SCNBox(width: 0.1, height: 0.1, length: 0.1, chamferRadius: 0.01)
let material = SCNMaterial()
material.lightingModel = .physicallyBased
material.diffuse.contents = UIColor.systemBlue
material.metalness.contents = 0.2
material.roughness.contents = 0.6
box.materials = [material]
let node = SCNNode(geometry: box)
node.position = SCNVector3(0, 0.05, 0)
parentNode.addChildNode(node)

After

var material = PhysicallyBasedMaterial()
material.baseColor = .init(tint: .systemBlue)
material.metallic = .init(floatLiteral: 0.2)
material.roughness = .init(floatLiteral: 0.6)

let mesh = MeshResource.generateBox(size: 0.1, cornerRadius: 0.01)
let box = ModelEntity(mesh: mesh, materials: [material])
box.position = [0, 0.05, 0]
parent.addChild(box)

The primitives map one-to-one: SCNBox, SCNSphere, SCNPlane, SCNCylinder become MeshResource.generateBox/Sphere/Plane/Cylinder. SCNText becomes MeshResource.generateText(_:extrusionDepth:font:). Texture maps move from diffuse.contents = UIImage to material.baseColor = .init(texture: .init(try TextureResource(named: "wood"))).

SCNMaterial.lightingModel = .constant (unlit) becomes UnlitMaterial. Custom SCNProgram shaders have no port path; you rewrite them as a ShaderGraphMaterial in Reality Composer Pro or a CustomMaterial with a Metal surface shader.

4. Loading models

Before you probably converted assets to .scn or loaded .dae/.obj through SCNScene(named:) or Model I/O.

After, the asset format is USDZ. Convert once with Reality Converter (part of the developer tools) or usdzconvert, check the result in Reality Composer Pro, then:

let model = try await Entity(named: "chair")      // Bundle USDZ or .reality
model.scale = [0.01, 0.01, 0.01]

Keep your asset units consistent: USDZ files authored in centimeters need the 0.01 scale shown; files authored in meters do not.

5. Lighting

A SceneKit scene with hand-placed SCNLight nodes is the most common source of "it looks wrong after migration." RealityKit on iOS uses image-based lighting estimated from the camera by default, so the first thing to try is deleting your lights. If the scene needs an explicit key light:

let sun = DirectionalLight()
sun.light.intensity = 2000
sun.shadow = DirectionalLightComponent.Shadow()
sun.orientation = simd_quatf(angle: -.pi / 3, axis: [1, 0, 0])
content.add(sun)

Shadows on real surfaces that SceneKit got from ARSCNView's environment lighting come from GroundingShadowComponent on the entity in RealityKit.

6. Animation

Before

let spin = SCNAction.rotateBy(x: 0, y: .pi * 2, z: 0, duration: 4)
node.runAction(SCNAction.repeatForever(spin))

After

let spin = FromToByAnimation<Transform>(
    by: Transform(rotation: simd_quatf(angle: .pi * 2, axis: [0, 1, 0])),
    duration: 4,
    bindTarget: .transform
)
let resource = try AnimationResource.generate(with: spin)
entity.playAnimation(resource.repeat())

One-shot moves are simpler: entity.move(to: transform, relativeTo: parent, duration: 0.5). Skeletal animations baked into a USDZ are available as entity.availableAnimations and play with the same playAnimation call — no CAAnimation bridging.

7. Hit testing and gestures

Before

@objc func handleTap(_ gesture: UITapGestureRecognizer) {
    let point = gesture.location(in: sceneView)
    if let hit = sceneView.hitTest(point, options: nil).first {
        select(hit.node)
    }
}

After

model.generateCollisionShapes(recursive: true)
model.components.set(InputTargetComponent())

RealityView { content in /* ... */ }
    .gesture(
        TapGesture()
            .targetedToAnyEntity()
            .onEnded { value in select(value.entity) }
    )

Raycasts against real-world surfaces (sceneView.raycastQuery) stay in ARKit and work identically through ARView.session or your own ARSession.

8. Physics

SCNPhysicsBody(type: .dynamic, shape: nil) becomes two components:

box.components.set(CollisionComponent(shapes: [.generateBox(size: [0.1, 0.1, 0.1])]))
box.components.set(PhysicsBodyComponent(massProperties: .default,
                                        material: .default,
                                        mode: .dynamic))

Collision callbacks move from SCNPhysicsContactDelegate to content.subscribe(to: CollisionEvents.Began.self).

9. Things with no direct equivalent

Be honest with your estimate about these:

  • SCNProgram / SCNTechnique custom rendering. Rewrite as Reality Composer Pro shader graphs or CustomMaterial.
  • Procedural geometry updated every frame. LowLevelMesh (iOS 18+) handles it, but it is a GPU-buffer API, not a SCNGeometrySource array.
  • SCNParticleSystem. Use ParticleEmitterComponent, authored in Reality Composer Pro where possible; the parameter set differs.
  • Scene-graph tricks such as SCNLookAtConstraint and billboard constraints. RealityKit has BillboardComponent (iOS 18+); look-at behavior is a two-line System.
  • SceneKit editor scenes (.scn). Re-author in Reality Composer Pro.

Migration order that works

  1. Swap the view and get the camera feed rendering through RealityView with an empty scene.
  2. Convert assets to USDZ and load them with no behavior.
  3. Port anchoring, then materials and lighting, comparing against the old build side by side.
  4. Port interaction and animation.
  5. Delete the SceneKit import and the ARSCNViewDelegate.

Keep the SceneKit build shippable on main throughout; the migration lives on a branch until step 5 passes your regression checklist.

If you would rather hand this to people who have done it before, RealityRogue offers a free migration assessment. Contact us.