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/SCNTechniquecustom rendering. Rewrite as Reality Composer Pro shader graphs orCustomMaterial.- Procedural geometry updated every frame.
LowLevelMesh(iOS 18+) handles it, but it is a GPU-buffer API, not aSCNGeometrySourcearray. SCNParticleSystem. UseParticleEmitterComponent, authored in Reality Composer Pro where possible; the parameter set differs.- Scene-graph tricks such as
SCNLookAtConstraintand billboard constraints. RealityKit hasBillboardComponent(iOS 18+); look-at behavior is a two-lineSystem. - SceneKit editor scenes (
.scn). Re-author in Reality Composer Pro.
Migration order that works
- Swap the view and get the camera feed rendering through
RealityViewwith an empty scene. - Convert assets to USDZ and load them with no behavior.
- Port anchoring, then materials and lighting, comparing against the old build side by side.
- Port interaction and animation.
- 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.