Skillshub axiom-realitykit
Use when building 3D content, AR experiences, or spatial computing with RealityKit. Covers ECS architecture, SwiftUI integration, RealityView, AR anchors, materials, physics, interaction, multiplayer, performance.
git clone https://github.com/ComeOnOliver/skillshub
T=$(mktemp -d) && git clone --depth=1 https://github.com/ComeOnOliver/skillshub "$T" && mkdir -p ~/.claude/skills && cp -r "$T/skills/CharlesWiltgen/Axiom/axiom-realitykit" ~/.claude/skills/comeonoliver-skillshub-axiom-realitykit && rm -rf "$T"
skills/CharlesWiltgen/Axiom/axiom-realitykit/SKILL.mdRealityKit 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 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 |
|---|---|
| Position, rotation, scale |
| Mesh geometry + materials |
| Collision shapes for physics and interaction |
| Mass, physics mode (dynamic/static/kinematic) |
| Linear and angular velocity |
| AR anchor attachment |
| Multiplayer sync |
| Camera settings |
| Directional light |
| Point light |
| Spot light |
| Character physics controller |
| Audio mixing |
| 3D positional audio |
| Non-positional audio |
| Multi-channel audio |
| Entity transparency |
| Contact shadow |
| Gesture input (visionOS) |
| Hover highlight (visionOS) |
| 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
) to place content appropriately.wall - 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 |
|---|---|---|
| Solid color or texture | Color, metallic, roughness |
| Full PBR | All PBR maps (base color, normal, metallic, roughness, AO, emissive) |
| No lighting response | Color or texture, always fully lit |
| Invisible but occludes | AR content hiding behind real objects |
| Video playback on surface | AVPlayer-driven |
| Custom shader graph | Reality Composer Pro |
| 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
is convenient but expensivegenerateCollisionShapes(recursive: true)- 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
before useregisterComponent() - Systems registered via
before scene loadsregisterSystem() - 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
-
in closure-based subscriptions if retaining view/controller[weak self]
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