Comprehensive RealityKit skill optimized for building AR companion experiences on iOS and visionOS, with character animation, body/hand tracking, AI integration patterns, spatial audio, and entity...
A production-ready skill for developing AR companion applications using Apple's RealityKit framework, specifically tailored for creating interactive, persistent virtual characters.
import SwiftUI
import RealityKit
import ARKit
struct ARCompanionView: View {
var body: some View {
ARViewContainer()
.edgesIgnoringSafeArea(.all)
}
}
struct ARViewContainer: UIViewRepresentable {
func makeUIView(context: Context) -> ARView {
let arView = ARView(frame: .zero)
// Configure AR session for companion placement
let config = ARWorldTrackingConfiguration()
config.planeDetection = [.horizontal]
config.environmentTexturing = .automatic
arView.session.run(config)
// Create companion anchor on horizontal surface
let anchor = AnchorEntity(plane: .horizontal)
arView.scene.addAnchor(anchor)
// Load companion entity asynchronously
Task {
do {
let companion = try await Entity.loadAsync(named: "Companion")
anchor.addChild(companion)
} catch {
print("Failed to load companion: \(error)")
}
}
return arView
}
func updateUIView(_ uiView: ARView, context: Context) {}
}
RealityKit uses an ECS architecture ideal for companion characters:
// Custom companion component
struct CompanionComponent: Component {
var mood: CompanionMood = .neutral
var energy: Float = 1.0
var isInteracting: Bool = false
}
enum CompanionMood {
case happy, neutral, curious, tired
}
// Register component before use
CompanionComponent.registerComponent()
class CompanionBehaviorSystem: System {
static let query = EntityQuery(where: .has(CompanionComponent.self))
required init(scene: Scene) {}
func update(context: SceneUpdateContext) {
for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) {
guard var companion = entity.components[CompanionComponent.self] else { continue }
// Update companion state based on time/interaction
companion.energy -= 0.0001 * Float(context.deltaTime)
if companion.energy < 0.3 {
companion.mood = .tired
}
entity.components[CompanionComponent.self] = companion
}
}
}
// Register system at app launch
CompanionBehaviorSystem.registerSystem()
// Async loading of USDZ character with animations
func loadCompanionCharacter() async throws -> Entity {
let character = try await Entity.load(named: "CompanionCharacter")
// Access available animations
if let animationResource = character.availableAnimations.first {
character.playAnimation(animationResource.repeat())
}
return character
}
class CompanionAnimator {
var companion: Entity
var currentController: AnimationPlaybackController?
init(companion: Entity) {
self.companion = companion
}
func playAnimation(named name: String, loop: Bool = false) {
// Stop current animation
currentController?.stop()
// Find and play new animation
if let animation = companion.availableAnimations.first(where: {
$0.name?.contains(name) == true
}) {
let resource = loop ? animation.repeat() : animation
currentController = companion.playAnimation(resource)
}
}
func transitionToIdle() {
playAnimation(named: "idle", loop: true)
}
func playReaction(type: ReactionType) {
switch type {
case .wave:
playAnimation(named: "wave")
case .jump:
playAnimation(named: "jump")
case .nod:
playAnimation(named: "nod")
}
}
}
enum ReactionType {
case wave, jump, nod
}
import RealityActions
// Register at app startup
ActionManagerSystem.registerSystem()
// Procedural bounce animation
func makeCompanionBounce(_ entity: Entity) {
entity.start(RepeatForever(
Sequence(
MoveBy(duration: 0.3, delta: SIMD3(0, 0.1, 0)),
MoveBy(duration: 0.3, delta: SIMD3(0, -0.1, 0))
)
))
}
// Attention-grabbing wiggle
func wiggleForAttention(_ entity: Entity) {
entity.start(Sequence(
RotateBy(duration: 0.1, deltaAngles: SIMD3(0, 0.1, 0)),
RotateBy(duration: 0.1, deltaAngles: SIMD3(0, -0.2, 0)),
RotateBy(duration: 0.1, deltaAngles: SIMD3(0, 0.1, 0))
))
}
// Look at user smoothly
func lookAtUser(_ entity: Entity, userPosition: SIMD3<Float>) async {
let direction = normalize(userPosition - entity.position)
let targetRotation = simd_quatf(from: SIMD3(0, 0, 1), to: direction)
await entity.run(RotateTo(duration: 0.5, orientation: targetRotation))
}
Using RealityMorpher for blend shape animations (iOS/macOS only, not visionOS):
import RealityMorpher
// Apply facial expression blend shapes
func setCompanionExpression(_ entity: Entity, expression: FacialExpression) {
// Requires model with blend shapes exported from 3D software
// Set morph target weights for expressions
switch expression {
case .happy:
// Set smile blend shape weight
entity.setMorphWeight(named: "smile", weight: 1.0)
entity.setMorphWeight(named: "eyeWide", weight: 0.3)
case .sad:
entity.setMorphWeight(named: "frown", weight: 0.8)
entity.setMorphWeight(named: "eyeNarrow", weight: 0.4)
case .surprised:
entity.setMorphWeight(named: "mouthOpen", weight: 0.7)
entity.setMorphWeight(named: "eyeWide", weight: 1.0)
}
}
enum FacialExpression {
case happy, sad, surprised, neutral
}
import RKLoader
class BodyTrackingManager {
var arView: ARView
var trackedCharacter: BodyTrackedEntity?
init(arView: ARView) {
self.arView = arView
}
func startBodyTracking() async throws {
// Load body-tracked character
let character = try await RKLoader.loadBodyTrackedEntityAsync(named: "TrackedCompanion")
trackedCharacter = character
// Create body anchor
let bodyAnchor = AnchorEntity(.body)
arView.scene.addAnchor(bodyAnchor)
bodyAnchor.addChild(character)
}
func stopBodyTracking() {
trackedCharacter?.removeFromParent()
trackedCharacter = nil
}
}
import HandVector
class HandInteractionManager: ObservableObject {
@Published var detectedGesture: HandGesture = .none
private let handTracking = HandTracking()
func processHandUpdate(anchor: HandAnchor) {
let handInfo = handTracking.generateHandInfo(from: anchor)
// Check against built-in gestures
if let similarity = handInfo?.similarity(of: .fiveFingers, to: .thumbsUp),
similarity > 0.8 {
detectedGesture = .thumbsUp
return
}
if let similarity = handInfo?.similarity(of: .fiveFingers, to: .wave),
similarity > 0.8 {
detectedGesture = .wave
return
}
// Check pinch gesture using FingerShape
if let fingerShape = handInfo?.fingerShape,
fingerShape.pinchDistance < 0.02 {
detectedGesture = .pinch
return
}
detectedGesture = .none
}
}
enum HandGesture {
case none, thumbsUp, wave, pinch, pointAt
}
class GestureResponsiveCompanion {
var companion: Entity
var animator: CompanionAnimator
func respondToGesture(_ gesture: HandGesture) {
switch gesture {
case .thumbsUp:
animator.playReaction(type: .jump)
playHappySound()
case .wave:
animator.playReaction(type: .wave)
case .pinch:
// Start interaction mode
startInteractionMode()
case .pointAt:
// Look where user is pointing
followPointDirection()
case .none:
break
}
}
}
class CompanionAI {
enum State {
case idle
case following
case playing
case resting
case responding
}
var currentState: State = .idle
var companion: Entity
var animator: CompanionAnimator
private var stateTimer: Double = 0
private var attentionTarget: SIMD3<Float>?
func update(deltaTime: Double, userPosition: SIMD3<Float>) {
stateTimer += deltaTime
switch currentState {
case .idle:
handleIdleState(userPosition: userPosition)
case .following:
handleFollowingState(userPosition: userPosition)
case .playing:
handlePlayingState()
case .resting:
handleRestingState()
case .responding:
handleRespondingState()
}
}
private func handleIdleState(userPosition: SIMD3<Float>) {
// Occasionally look around
if stateTimer > 3.0 {
lookAtRandomPoint()
stateTimer = 0
}
// If user is far, start following
let distance = length(userPosition - companion.position)
if distance > 2.0 {
transitionTo(.following)
}
}
private func handleFollowingState(userPosition: SIMD3<Float>) {
let distance = length(userPosition - companion.position)
// Move toward user
if distance > 1.0 {
let direction = normalize(userPosition - companion.position)
let targetPosition = userPosition - direction * 1.0
moveToward(targetPosition)
} else {
transitionTo(.idle)
}
}
private func transitionTo(_ newState: State) {
currentState = newState
stateTimer = 0
switch newState {
case .idle:
animator.transitionToIdle()
case .following:
animator.playAnimation(named: "walk", loop: true)
case .playing:
animator.playAnimation(named: "play", loop: true)
case .resting:
animator.playAnimation(named: "rest", loop: true)
case .responding:
break // Animation set by trigger
}
}
}
protocol BehaviorNode {
func evaluate(context: CompanionContext) -> BehaviorResult
}
enum BehaviorResult {
case success
case failure
case running
}
struct CompanionContext {
var companion: Entity
var userPosition: SIMD3<Float>
var energy: Float
var mood: CompanionMood
var deltaTime: Double
}
// Sequence: Run children in order until one fails
class SequenceNode: BehaviorNode {
var children: [BehaviorNode]
func evaluate(context: CompanionContext) -> BehaviorResult {
for child in children {
let result = child.evaluate(context: context)
if result != .success {
return result
}
}
return .success
}
}
// Selector: Run children until one succeeds
class SelectorNode: BehaviorNode {
var children: [BehaviorNode]
func evaluate(context: CompanionContext) -> BehaviorResult {
for child in children {
let result = child.evaluate(context: context)
if result == .success {
return .success
}
}
return .failure
}
}
// Example leaf nodes
class CheckEnergyNode: BehaviorNode {
let threshold: Float
func evaluate(context: CompanionContext) -> BehaviorResult {
return context.energy > threshold ? .success : .failure
}
}
class MoveToUserNode: BehaviorNode {
func evaluate(context: CompanionContext) -> BehaviorResult {
let distance = length(context.userPosition - context.companion.position)
if distance < 1.0 {
return .success
}
// Move toward user
let direction = normalize(context.userPosition - context.companion.position)
context.companion.position += direction * 0.01
return .running
}
}
func setupCompanionAudio(_ companion: Entity) {
// Add spatial audio component
var spatialAudio = SpatialAudioComponent()
spatialAudio.gain = 0.8
spatialAudio.directivity = .beam(focus: 0.5)
companion.components.set(spatialAudio)
}
func playCompanionSound(_ companion: Entity, soundName: String) async {
guard let audioResource = try? await AudioFileResource.load(
named: soundName,
in: nil,
inputMode: .spatial,
loadingStrategy: .preload,
shouldLoop: false
) else { return }
let audioController = companion.playAudio(audioResource)
}
class CompanionAudioManager {
var companion: Entity
var ambientController: AudioPlaybackController?
func startAmbientSounds() async {
guard let ambient = try? await AudioFileResource.load(
named: "companion_ambient",
inputMode: .spatial,
loadingStrategy: .stream,
shouldLoop: true
) else { return }
ambientController = companion.playAudio(ambient)
ambientController?.gain = 0.3
}
func playReactionSound(for mood: CompanionMood) async {
let soundName: String
switch mood {
case .happy:
soundName = "companion_happy"
case .curious:
soundName = "companion_curious"
case .tired:
soundName = "companion_yawn"
case .neutral:
soundName = "companion_chirp"
}
guard let sound = try? await AudioFileResource.load(
named: soundName,
inputMode: .spatial,
shouldLoop: false
) else { return }
companion.playAudio(sound)
}
func stopAmbient() {
ambientController?.stop()
ambientController = nil
}
}
@MainActor
class CompanionLifecycleManager: ObservableObject {
@Published var companion: Entity?
@Published var isLoaded: Bool = false
@Published var companionState: CompanionSaveState?
private var arView: ARView?
private var anchor: AnchorEntity?
// MARK: - Lifecycle
func initialize(arView: ARView) async throws {
self.arView = arView
// Create anchor for companion
anchor = AnchorEntity(plane: .horizontal)
arView.scene.addAnchor(anchor!)
// Load companion
let loadedCompanion = try await Entity.load(named: "Companion")
companion = loadedCompanion
anchor?.addChild(loadedCompanion)
// Restore saved state if available
if let savedState = loadSavedState() {
applyState(savedState)
}
isLoaded = true
}
func suspend() {
// Save state before suspending
saveCurrentState()
// Pause animations
companion?.availableAnimations.forEach { animation in
companion?.stopAllAnimations()
}
}
func resume() {
// Resume idle animation
if let idle = companion?.availableAnimations.first(where: {
$0.name?.contains("idle") == true
}) {
companion?.playAnimation(idle.repeat())
}
}
func cleanup() {
saveCurrentState()
companion?.removeFromParent()
anchor?.removeFromParent()
companion = nil
anchor = nil
isLoaded = false
}
// MARK: - Persistence
func saveCurrentState() {
guard let companion = companion else { return }
let state = CompanionSaveState(
position: companion.position,
orientation: companion.orientation,
energy: companion.components[CompanionComponent.self]?.energy ?? 1.0,
mood: companion.components[CompanionComponent.self]?.mood ?? .neutral,
lastInteraction: Date()
)
if let encoded = try? JSONEncoder().encode(state) {
UserDefaults.standard.set(encoded, forKey: "companionState")
}
}
func loadSavedState() -> CompanionSaveState? {
guard let data = UserDefaults.standard.data(forKey: "companionState"),
let state = try? JSONDecoder().decode(CompanionSaveState.self, from: data) else {
return nil
}
return state
}
func applyState(_ state: CompanionSaveState) {
companion?.position = state.position
companion?.orientation = state.orientation
var component = CompanionComponent()
component.energy = state.energy
component.mood = state.mood
companion?.components.set(component)
companionState = state
}
}
struct CompanionSaveState: Codable {
var position: SIMD3<Float>
var orientation: simd_quatf
var energy: Float
var mood: CompanionMood
var lastInteraction: Date
}
extension CompanionMood: Codable {}
extension SIMD3: Codable where Scalar: Codable {}
extension simd_quatf: Codable {}
import SwiftUI
struct ARCompanionApp: App {
@StateObject var companionManager = CompanionLifecycleManager()
@Environment(\.scenePhase) var scenePhase
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(companionManager)
.onChange(of: scenePhase) { oldPhase, newPhase in
switch newPhase {
case .active:
companionManager.resume()
case .inactive:
companionManager.suspend()
case .background:
companionManager.saveCurrentState()
@unknown default:
break
}
}
}
}
}
class CompanionInteractionHandler {
var arView: ARView
var companion: Entity?
init(arView: ARView) {
self.arView = arView
setupGestures()
}
private func setupGestures() {
let tapGesture = UITapGestureRecognizer(target: self, action: #selector(handleTap))
arView.addGestureRecognizer(tapGesture)
let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePan))
arView.addGestureRecognizer(panGesture)
}
@objc func handleTap(_ gesture: UITapGestureRecognizer) {
let location = gesture.location(in: arView)
// Check if tapped on companion
if let hitEntity = arView.entity(at: location),
hitEntity == companion || hitEntity.isDescendant(of: companion!) {
onCompanionTapped()
} else {
// Raycast to find new position for companion
if let result = arView.raycast(from: location,
allowing: .estimatedPlane,
alignment: .horizontal).first {
moveCompanionTo(result.worldTransform.translation)
}
}
}
@objc func handlePan(_ gesture: UIPanGestureRecognizer) {
guard let companion = companion else { return }
let location = gesture.location(in: arView)
switch gesture.state {
case .changed:
if let result = arView.raycast(from: location,
allowing: .estimatedPlane,
alignment: .horizontal).first {
companion.position = result.worldTransform.translation
}
case .ended:
// Companion reached destination
onCompanionMoved()
default:
break
}
}
func onCompanionTapped() {
// Trigger interaction response
companion?.components[CompanionComponent.self]?.isInteracting = true
}
func moveCompanionTo(_ position: SIMD3<Float>) {
// Animate companion to new position
companion?.move(to: Transform(translation: position),
relativeTo: companion?.parent,
duration: 1.0)
}
func onCompanionMoved() {
// Play arrival animation
}
}
// Helper extension
extension simd_float4x4 {
var translation: SIMD3<Float> {
SIMD3(columns.3.x, columns.3.y, columns.3.z)
}
}
import RealityUI
class CompanionUIManager {
var arView: ARView
var companion: Entity?
init(arView: ARView) {
self.arView = arView
RealityUI.registerComponents()
RealityUI.enableGestures(.all, on: arView)
}
func addInteractionControls() {
guard let companion = companion else { return }
// Add a toggle for companion following mode
let followToggle = RUISwitch()
followToggle.position = SIMD3(0.3, 0.5, 0)
followToggle.changedCallback = { [weak self] toggle in
self?.setFollowMode(toggle.isOn)
}
companion.addChild(followToggle)
// Add mood slider
let moodSlider = RUISlider(length: 0.5, start: 0.5)
moodSlider.position = SIMD3(-0.3, 0.5, 0)
moodSlider.updateCallback = { [weak self] slider, state in
self?.updateCompanionMood(value: slider.value)
}
companion.addChild(moodSlider)
}
func setFollowMode(_ enabled: Bool) {
// Toggle following behavior
}
func updateCompanionMood(value: Float) {
// Map slider value to mood
}
}
func configureARViewForCompanion(_ arView: ARView) {
// Enable environment texturing for realistic lighting
let config = ARWorldTrackingConfiguration()
config.planeDetection = [.horizontal]
config.environmentTexturing = .automatic
config.frameSemantics.insert(.personSegmentationWithDepth)
// Enable people occlusion (companion appears behind real people)
if ARWorldTrackingConfiguration.supportsFrameSemantics(.personSegmentationWithDepth) {
arView.environment.sceneUnderstanding.options.insert(.occlusion)
}
// Configure rendering
arView.renderOptions = [
.disablePersonOcclusion, // Remove if you want occlusion
.disableMotionBlur
]
// Enable camera exposure adaptation
arView.environment.lighting.intensityExponent = 1.0
arView.session.run(config)
}
func enableDebugVisualization(_ arView: ARView, enabled: Bool) {
if enabled {
arView.debugOptions = [
.showFeaturePoints,
.showAnchorOrigins,
.showPhysics,
.showSceneUnderstanding
]
} else {
arView.debugOptions = []
}
}
Companion Not Appearing
// Ensure anchor is properly configured
let anchor = AnchorEntity(plane: .horizontal, minimumBounds: [0.2, 0.2])
// Check if plane detection is working
arView.session.delegate = self
extension YourClass: ARSessionDelegate {
func session(_ session: ARSession, didAdd anchors: [ARAnchor]) {
for anchor in anchors {
if let planeAnchor = anchor as? ARPlaneAnchor {
print("Plane detected: \(planeAnchor.extent)")
}
}
}
}
Animation Not Playing
// Check available animations
print("Available animations: \(entity.availableAnimations.map { $0.name })")
// Ensure animation resource is valid
guard !entity.availableAnimations.isEmpty else {
print("No animations found in entity")
return
}
Performance Issues
// Reduce draw calls
entity.generateCollisionShapes(recursive: true) // Do once, not per frame
// Use simpler collision shapes
let simpleShape = ShapeResource.generateSphere(radius: 0.5)
entity.collision = CollisionComponent(shapes: [simpleShape])
// Limit update frequency for AI
class ThrottledCompanionAI {
var updateInterval: Double = 0.1 // 10 updates per second
var timeSinceUpdate: Double = 0
func update(deltaTime: Double) {
timeSinceUpdate += deltaTime
if timeSinceUpdate >= updateInterval {
performUpdate()
timeSinceUpdate = 0
}
}
}
Companion Drifts or Moves Unexpectedly
// Use world tracking quality checks
extension YourClass: ARSessionDelegate {
func session(_ session: ARSession, cameraDidChangeTrackingState camera: ARCamera) {
switch camera.trackingState {
case .limited(let reason):
handleLimitedTracking(reason)
case .normal:
resumeNormalOperation()
case .notAvailable:
showTrackingUnavailableUI()
}
}
}
Skill Version: 2.0.0-enhanced Focus: AR Companion Development Platforms: iOS 15+, visionOS 1.0+ Generated by: Metatron (Enhanced from Skill Seeker output)