axiom-realitykit
RealityKit Development Guide
Purpose: Build 3D content, AR experiences, and spatial computing apps using RealityKit's Entity-Component-System architecture iOS Version: iOS 13+ (base), iOS 18+ (RealityView on iOS), visionOS 1.0+ Xcode: Xcode 15+
When to Use This Skill
Use this skill when:
- Building any 3D experience (AR, games, visualization, spatial computing)
- Creating SwiftUI apps with 3D content (RealityView, Model3D)
- Implementing AR with anchors (world, image, face, body tracking)
- Working with Entity-Component-System (ECS) architecture
- Setting up physics, collisions, or spatial interactions
- Building multiplayer or shared AR experiences
- Migrating from SceneKit to RealityKit
- Targeting visionOS
Do NOT use this skill for:
- SceneKit maintenance (use
axiom-scenekit) - 2D games (use
axiom-spritekit) - Metal shader programming (use
axiom-metal-migration-ref) - Pure GPU compute (use Metal directly)
1. Mental Model: ECS vs Scene Graph
Scene Graph (SceneKit)
In SceneKit, nodes own their properties. A node IS a renderable, collidable, animated thing.
Entity-Component-System (RealityKit)
In RealityKit, entities are empty containers. Components add data. Systems process that data.
Entity (identity + hierarchy)
├── TransformComponent (position, rotation, scale)
├── ModelComponent (mesh + materials)
├── CollisionComponent (collision shapes)
├── PhysicsBodyComponent (mass, mode)
└── [YourCustomComponent] (game-specific data)
System (processes entities with specific components each frame)
Why ECS matters:
- Composition over inheritance: Combine any components on any entity
- Data-oriented: Systems process arrays of components efficiently
- Decoupled logic: Systems don't know about each other
- Testable: Components are pure data, Systems are pure logic
The ECS Mental Shift
| Scene Graph Thinking | ECS Thinking |
|---|---|
| "The player node moves" | "The movement system processes entities with MovementComponent" |
| "Add a method to the node subclass" | "Add a component, create a system" |
"Override update(_:) in the node" |
"Register a System that queries for components" |
| "The node knows its health" | "HealthComponent holds data, DamageSystem processes it" |
2. Entity Hierarchy
Creating Entities
// Empty entity
let entity = Entity()
entity.name = "player"
// Entity with components
let entity = Entity()
entity.components[ModelComponent.self] = ModelComponent(
mesh: .generateBox(size: 0.1),
materials: [SimpleMaterial(color: .blue, isMetallic: false)]
)
// ModelEntity convenience (has ModelComponent built in)
let box = ModelEntity(
mesh: .generateBox(size: 0.1),
materials: [SimpleMaterial(color: .red, isMetallic: true)]
)
Hierarchy Management
// Parent-child
parent.addChild(child)
child.removeFromParent()
// Find entities
let found = root.findEntity(named: "player")
// Enumerate
for child in entity.children {
// Process children
}
// Clone
let clone = entity.clone(recursive: true)
Transform
// Local transform (relative to parent)
entity.position = SIMD3<Float>(0, 1, 0)
entity.orientation = simd_quatf(angle: .pi / 4, axis: SIMD3(0, 1, 0))
entity.scale = SIMD3<Float>(repeating: 2.0)
// World-space queries
let worldPos = entity.position(relativeTo: nil)
let worldTransform = entity.transform(relativeTo: nil)
// Set world-space transform
entity.setPosition(SIMD3(1, 0, 0), relativeTo: nil)
// Look at a point
entity.look(at: targetPosition, from: entity.position, relativeTo: nil)
3. Components
Built-in Components
| Component | Purpose |
|---|---|
Transform |
Position, rotation, scale |
ModelComponent |
Mesh geometry + materials |
CollisionComponent |
Collision shapes for physics and interaction |
PhysicsBodyComponent |
Mass, physics mode (dynamic/static/kinematic) |
PhysicsMotionComponent |
Linear and angular velocity |
AnchoringComponent |
AR anchor attachment |
SynchronizationComponent |
Multiplayer sync |
PerspectiveCameraComponent |
Camera settings |
DirectionalLightComponent |
Directional light |
PointLightComponent |
Point light |
SpotLightComponent |
Spot light |
CharacterControllerComponent |
Character physics controller |
AudioMixGroupsComponent |
Audio mixing |
SpatialAudioComponent |
3D positional audio |
AmbientAudioComponent |
Non-positional audio |
ChannelAudioComponent |
Multi-channel audio |
OpacityComponent |
Entity transparency |
GroundingShadowComponent |
Contact shadow |
InputTargetComponent |
Gesture input (visionOS) |
HoverEffectComponent |
Hover highlight (visionOS) |
AccessibilityComponent |
VoiceOver support |
Custom Components
struct HealthComponent: Component {
var current: Int
var maximum: Int
var percentage: Float {
Float(current) / Float(maximum)
}
}
// Register before use (typically in app init)
HealthComponent.registerComponent()
// Attach to entity
entity.components[HealthComponent.self] = HealthComponent(current: 100, maximum: 100)
// Read
if let health = entity.components[HealthComponent.self] {
print(health.current)
}
// Modify
entity.components[HealthComponent.self]?.current -= 10
Component Lifecycle
Components are value types (structs). When you read a component, modify it, and write it back, you're replacing the entire component:
// Read-modify-write pattern
var health = entity.components[HealthComponent.self]!
health.current -= damage
entity.components[HealthComponent.self] = health
Anti-pattern: Holding a reference to a component and expecting mutations to propagate. Components are copied on read.
4. Systems
System Protocol
struct DamageSystem: System {
// Define which components this system needs
static let query = EntityQuery(where: .has(HealthComponent.self))
init(scene: RealityKit.Scene) {
// One-time setup
}
func update(context: SceneUpdateContext) {
for entity in context.entities(matching: Self.query,
updatingSystemWhen: .rendering) {
var health = entity.components[HealthComponent.self]!
if health.current <= 0 {
entity.removeFromParent()
}
}
}
}
// Register system
DamageSystem.registerSystem()
System Best Practices
- One responsibility per system: MovementSystem, DamageSystem, RenderingSystem — not GameLogicSystem
- Query filtering: Use precise queries to avoid processing irrelevant entities
- Order matters: Systems run in registration order. Register dependencies first.
- Avoid storing entity references: Query each frame instead. Entity references can become stale.
Event Handling
// Subscribe to collision events
scene.subscribe(to: CollisionEvents.Began.self) { event in
let entityA = event.entityA
let entityB = event.entityB
// Handle collision
}
// Subscribe to scene update
scene.subscribe(to: SceneEvents.Update.self) { event in
let deltaTime = event.deltaTime
// Per-frame logic
}
5. SwiftUI Integration
RealityView (iOS 18+, visionOS 1.0+)
struct ContentView: View {
var body: some View {
RealityView { content in
// make closure — called once
let box = ModelEntity(
mesh: .generateBox(size: 0.1),
materials: [SimpleMaterial(color: .blue, isMetallic: false)]
)
content.add(box)
} update: { content in
// update closure — called when SwiftUI state changes
}
}
}
RealityView with Camera (iOS)
On iOS, RealityView provides a camera content parameter for configuring the AR or virtual camera:
RealityView { content, attachments in
// Load 3D content
if let model = try? await ModelEntity(named: "scene") {
content.add(model)
}
}
Loading Content Asynchronously
RealityView { content in
// Load from bundle
if let entity = try? await Entity(named: "MyScene", in: .main) {
content.add(entity)
}
// Load from URL
if let entity = try? await Entity(contentsOf: modelURL) {
content.add(entity)
}
}
Model3D (Simple Display)
// Simple 3D model display (no interaction)
Model3D(named: "toy_robot") { model in
model
.resizable()
.scaledToFit()
} placeholder: {
ProgressView()
}
SwiftUI Attachments (visionOS)
RealityView { content, attachments in
let entity = ModelEntity(mesh: .generateSphere(radius: 0.1))
content.add(entity)
if let label = attachments.entity(for: "priceTag") {
label.position = SIMD3(0, 0.15, 0)
entity.addChild(label)
}
} attachments: {
Attachment(id: "priceTag") {
Text("$9.99")
.padding()
.glassBackgroundEffect()
}
}
State Binding Pattern
struct GameView: View {
@State private var score = 0
var body: some View {
VStack {
Text("Score: \(score)")
RealityView { content in
let scene = try! await Entity(named: "GameScene")
content.add(scene)
} update: { content in
// React to state changes
// Note: update is called when SwiftUI state changes,
// not every frame. Use Systems for per-frame logic.
}
}
}
}
6. AR on iOS
AnchorEntity
// Horizontal plane
let anchor = AnchorEntity(.plane(.horizontal, classification: .table,
minimumBounds: SIMD2(0.2, 0.2)))
// Vertical plane
let anchor = AnchorEntity(.plane(.vertical, classification: .wall,
minimumBounds: SIMD2(0.5, 0.5)))
// World position
let anchor = AnchorEntity(world: SIMD3<Float>(0, 0, -1))
// Image anchor
let anchor = AnchorEntity(.image(group: "AR Resources", name: "poster"))
// Face anchor (front camera)
let anchor = AnchorEntity(.face)
// Body anchor
let anchor = AnchorEntity(.body)
SpatialTrackingSession (iOS 18+)
let session = SpatialTrackingSession()
let configuration = SpatialTrackingSession.Configuration(tracking: [.plane, .object])
let result = await session.run(configuration)
if let notSupported = result {
// Handle unsupported tracking on this device
for denied in notSupported.deniedTrackingModes {
print("Not supported: \(denied)")
}
}
AR Best Practices
- Anchor entities to detected surfaces rather than world positions for stability
- Use plane classification (
.table,.floor,.wall) to place content appropriately - Start with horizontal plane detection — it's the most reliable
- Test on real devices; simulator AR is limited
- Provide visual feedback during surface detection (coaching overlay)
7. Interaction
ManipulationComponent (iOS, visionOS)
// Enable drag, rotate, scale gestures
entity.components[ManipulationComponent.self] = ManipulationComponent(
allowedModes: .all // .translate, .rotate, .scale
)
// Also requires CollisionComponent for hit testing
entity.generateCollisionShapes(recursive: true)
InputTargetComponent (visionOS)
// Required for visionOS gesture input
entity.components[InputTargetComponent.self] = InputTargetComponent()
entity.components[CollisionComponent.self] = CollisionComponent(
shapes: [.generateBox(size: SIMD3(0.1, 0.1, 0.1))]
)
Gesture Integration with SwiftUI
RealityView { content in
let entity = ModelEntity(mesh: .generateBox(size: 0.1))
entity.generateCollisionShapes(recursive: true)
entity.components.set(InputTargetComponent())
content.add(entity)
}
.gesture(
TapGesture()
.targetedToAnyEntity()
.onEnded { value in
let tappedEntity = value.entity
// Handle tap
}
)
.gesture(
DragGesture()
.targetedToAnyEntity()
.onChanged { value in
value.entity.position = value.convert(value.location3D,
from: .local, to: .scene)
}
)
Hit Testing
// Ray-cast from screen point
if let result = arView.raycast(from: screenPoint,
allowing: .estimatedPlane,
alignment: .horizontal).first {
let worldPosition = result.worldTransform.columns.3
// Place entity at worldPosition
}
8. Materials and Rendering
Material Types
| Material | Purpose | Customization |
|---|---|---|
SimpleMaterial |
Solid color or texture | Color, metallic, roughness |
PhysicallyBasedMaterial |
Full PBR | All PBR maps (base color, normal, metallic, roughness, AO, emissive) |
UnlitMaterial |
No lighting response | Color or texture, always fully lit |
OcclusionMaterial |
Invisible but occludes | AR content hiding behind real objects |
VideoMaterial |
Video playback on surface | AVPlayer-driven |
ShaderGraphMaterial |
Custom shader graph | Reality Composer Pro |
CustomMaterial |
Metal shader functions | Full Metal control |
PhysicallyBasedMaterial
var material = PhysicallyBasedMaterial()
material.baseColor = .init(tint: .white,
texture: .init(try! .load(named: "albedo")))
material.metallic = .init(floatLiteral: 0.0)
material.roughness = .init(floatLiteral: 0.5)
material.normal = .init(texture: .init(try! .load(named: "normal")))
material.ambientOcclusion = .init(texture: .init(try! .load(named: "ao")))
material.emissiveColor = .init(color: .blue)
material.emissiveIntensity = 2.0
let entity = ModelEntity(
mesh: .generateSphere(radius: 0.1),
materials: [material]
)
OcclusionMaterial (AR)
// Invisible plane that hides 3D content behind it
let occluder = ModelEntity(
mesh: .generatePlane(width: 1, depth: 1),
materials: [OcclusionMaterial()]
)
occluder.position = SIMD3(0, 0, 0)
anchor.addChild(occluder)
Environment Lighting
// Image-based lighting
if let resource = try? await EnvironmentResource(named: "studio_lighting") {
// Apply via RealityView content
}
9. Physics and Collision
Collision Shapes
// Generate from mesh (accurate but expensive)
entity.generateCollisionShapes(recursive: true)
// Manual shapes (prefer for performance)
entity.components[CollisionComponent.self] = CollisionComponent(
shapes: [
.generateBox(size: SIMD3(0.1, 0.2, 0.1)), // Box
.generateSphere(radius: 0.1), // Sphere
.generateCapsule(height: 0.3, radius: 0.05) // Capsule
]
)
Physics Body
// Dynamic — physics simulation controls movement
entity.components[PhysicsBodyComponent.self] = PhysicsBodyComponent(
massProperties: .init(mass: 1.0),
material: .generate(staticFriction: 0.5,
dynamicFriction: 0.3,
restitution: 0.4),
mode: .dynamic
)
// Static — immovable collision surface
ground.components[PhysicsBodyComponent.self] = PhysicsBodyComponent(
mode: .static
)
// Kinematic — code-controlled, participates in collisions
platform.components[PhysicsBodyComponent.self] = PhysicsBodyComponent(
mode: .kinematic
)
Collision Groups and Filters
// Define groups
let playerGroup = CollisionGroup(rawValue: 1 << 0)
let enemyGroup = CollisionGroup(rawValue: 1 << 1)
let bulletGroup = CollisionGroup(rawValue: 1 << 2)
// Filter: player collides with enemies and bullets
entity.components[CollisionComponent.self] = CollisionComponent(
shapes: [.generateSphere(radius: 0.1)],
filter: CollisionFilter(
group: playerGroup,
mask: enemyGroup | bulletGroup
)
)
Collision Events
// Subscribe in RealityView make closure or System
scene.subscribe(to: CollisionEvents.Began.self, on: playerEntity) { event in
let otherEntity = event.entityA == playerEntity ? event.entityB : event.entityA
handleCollision(with: otherEntity)
}
Applying Forces
if var motion = entity.components[PhysicsMotionComponent.self] {
motion.linearVelocity = SIMD3(0, 5, 0) // Impulse up
entity.components[PhysicsMotionComponent.self] = motion
}
10. Animation
Transform Animation
// Animate to position over duration
entity.move(
to: Transform(
scale: SIMD3(repeating: 1.5),
rotation: simd_quatf(angle: .pi, axis: SIMD3(0, 1, 0)),
translation: SIMD3(0, 2, 0)
),
relativeTo: entity.parent,
duration: 2.0,
timingFunction: .easeInOut
)
Playing USD Animations
if let entity = try? await Entity(named: "character") {
// Play all available animations
for animation in entity.availableAnimations {
entity.playAnimation(animation.repeat())
}
}
Animation Playback Control
let controller = entity.playAnimation(animation)
controller.pause()
controller.resume()
controller.speed = 2.0 // 2x playback speed
controller.blendFactor = 0.5 // Blend with current state
11. Audio
Spatial Audio
// Load audio resource
let resource = try! AudioFileResource.load(named: "engine.wav",
configuration: .init(shouldLoop: true))
// Create entity with spatial audio
let audioEntity = Entity()
audioEntity.components[SpatialAudioComponent.self] = SpatialAudioComponent()
let controller = audioEntity.playAudio(resource)
// Position the audio source in 3D space
audioEntity.position = SIMD3(2, 0, -1)
Ambient Audio
entity.components[AmbientAudioComponent.self] = AmbientAudioComponent()
entity.playAudio(backgroundMusic)
12. Performance
Entity Count
- Under 100 entities: No concerns
- 100-1000 entities: Monitor with RealityKit debugger
- 1000+ entities: Use instancing and LOD strategies
Instancing
// Share mesh and material across many entities
let sharedMesh = MeshResource.generateSphere(radius: 0.01)
let sharedMaterial = SimpleMaterial(color: .white, isMetallic: false)
for i in 0..<1000 {
let entity = ModelEntity(mesh: sharedMesh, materials: [sharedMaterial])
entity.position = randomPosition()
parent.addChild(entity)
}
RealityKit automatically batches entities with identical mesh and material resources.
Component Churn
Anti-pattern: Creating and replacing components every frame.
// BAD — component allocation every frame
func update(context: SceneUpdateContext) {
for entity in context.entities(matching: query, updatingSystemWhen: .rendering) {
entity.components[ModelComponent.self] = ModelComponent(
mesh: .generateBox(size: 0.1),
materials: [newMaterial] // New allocation every frame
)
}
}
// GOOD — modify existing component
func update(context: SceneUpdateContext) {
for entity in context.entities(matching: query, updatingSystemWhen: .rendering) {
// Only update when actually needed
if needsUpdate {
var model = entity.components[ModelComponent.self]!
model.materials = [cachedMaterial]
entity.components[ModelComponent.self] = model
}
}
}
Collision Shape Optimization
- Use simple shapes (box, sphere, capsule) instead of mesh-based collision
generateCollisionShapes(recursive: true)is convenient but expensive- For static geometry, generate shapes once during setup
Profiling
Use Xcode's RealityKit debugger:
- Entity Inspector: View entity hierarchy and components
- Statistics Overlay: Entity count, draw calls, triangle count
- Physics Visualization: Show collision shapes
13. Multiplayer
Synchronization Basics
// Components sync automatically if they conform to Codable
struct ScoreComponent: Component, Codable {
var points: Int
}
// SynchronizationComponent controls what syncs
entity.components[SynchronizationComponent.self] = SynchronizationComponent()
MultipeerConnectivityService
let service = try MultipeerConnectivityService(session: mcSession)
// Entities with SynchronizationComponent auto-sync across peers
Ownership
- Only the owner of an entity can modify it
- Request ownership before modifying shared entities
- Non-Codable component data does not sync
14. Anti-Patterns
Anti-Pattern 1: UIKit-Style Thinking in ECS
Time cost: Hours of frustration from fighting the architecture
// BAD — subclassing Entity for behavior
class PlayerEntity: Entity {
func takeDamage(_ amount: Int) { /* logic in entity */ }
}
// GOOD — component holds data, system has logic
struct HealthComponent: Component { var hp: Int }
struct DamageSystem: System {
static let query = EntityQuery(where: .has(HealthComponent.self))
func update(context: SceneUpdateContext) {
// Process damage here
}
}
Anti-Pattern 2: Monolithic Entities
Time cost: Untestable, inflexible architecture
Don't put all game logic in one entity type. Split into components that can be mixed and matched.
Anti-Pattern 3: Frame-Based Updates Without Systems
Time cost: Missed frame updates, inconsistent behavior
// BAD — timer-based updates
Timer.scheduledTimer(withTimeInterval: 1/60, repeats: true) { _ in
entity.position.x += 0.01
}
// GOOD — System update
struct MovementSystem: System {
static let query = EntityQuery(where: .has(VelocityComponent.self))
func update(context: SceneUpdateContext) {
for entity in context.entities(matching: Self.query,
updatingSystemWhen: .rendering) {
let velocity = entity.components[VelocityComponent.self]!
entity.position += velocity.value * Float(context.deltaTime)
}
}
}
Anti-Pattern 4: Not Generating Collision Shapes for Interactive Entities
Time cost: 15-30 min debugging "why taps don't work"
Gestures require CollisionComponent. If an entity has InputTargetComponent (visionOS) or ManipulationComponent but no CollisionComponent, gestures will never fire.
Anti-Pattern 5: Storing Entity References in Systems
Time cost: Crashes from stale references
// BAD — entity might be removed between frames
struct BadSystem: System {
var playerEntity: Entity? // Stale reference risk
func update(context: SceneUpdateContext) {
playerEntity?.position.x += 0.1 // May crash
}
}
// GOOD — query each frame
struct GoodSystem: System {
static let query = EntityQuery(where: .has(PlayerComponent.self))
func update(context: SceneUpdateContext) {
for entity in context.entities(matching: Self.query,
updatingSystemWhen: .rendering) {
entity.position.x += Float(context.deltaTime)
}
}
}
15. Code Review Checklist
- Custom components registered via
registerComponent()before use - Systems registered via
registerSystem()before scene loads - Components are value types (structs), not classes
- Read-modify-write pattern used for component updates
- Interactive entities have
CollisionComponent - visionOS interactive entities have
InputTargetComponent - Collision shapes are simple (box/sphere/capsule) where possible
- No entity references stored across frames in Systems
- Mesh and material resources shared across identical entities
- Component updates only occur when values actually change
- USD/USDZ format used for 3D assets (not .scn)
- Async loading used for all model/scene loading
-
[weak self]in closure-based subscriptions if retaining view/controller
16. Pressure Scenarios
Scenario 1: "ECS Is Overkill for Our Simple App"
Pressure: Team wants to avoid learning ECS, just needs one 3D model displayed
Wrong approach: Skip ECS, jam all logic into RealityView closures.
Correct approach: Even simple apps benefit from ECS. A single ModelEntity in a RealityView is already using ECS — you're just not adding custom components yet. Start simple, add components as complexity grows.
Push-back template: "We're already using ECS — Entity and ModelComponent. The pattern scales. Adding a custom component when we need behavior is one struct definition, not an architecture change."
Scenario 2: "Just Use SceneKit, We Know It"
Pressure: Team has SceneKit experience, RealityKit is unfamiliar
Wrong approach: Build new features in SceneKit.
Correct approach: SceneKit is soft-deprecated. New features won't be added. Invest in RealityKit now — the ECS concepts transfer to other game engines (Unity, Unreal, Bevy) if needed.
Push-back template: "SceneKit is in maintenance mode — no new features, only security patches. Every line of SceneKit we write is migration debt. RealityKit's concepts (Entity, Component, System) are industry-standard ECS."
Scenario 3: "Make It Work Without Collision Shapes"
Pressure: Deadline, collision shape setup seems complex
Wrong approach: Skip collision shapes, use position-based proximity detection.
Correct approach: entity.generateCollisionShapes(recursive: true) takes one line. Without it, gestures won't work and physics won't collide. The "shortcut" creates more debugging time than it saves.
Push-back template: "Collision shapes are required for gestures and physics. It's one line: entity.generateCollisionShapes(recursive: true). Skipping it means gestures silently fail — a harder bug to diagnose."
Resources
WWDC: 2019-603, 2019-605, 2021-10074, 2022-10074, 2023-10080, 2023-10081, 2024-10103, 2024-10153
Docs: /realitykit, /realitykit/entity, /realitykit/realityview, /realitykit/modelentity, /realitykit/anchorentity, /realitykit/component
Skills: axiom-realitykit-ref, axiom-realitykit-diag, axiom-scenekit, axiom-scenekit-ref