👓

【Swift】visionOS でユーザーの位置に合わせてモデルを表示する

2024/12/24に公開

初めに

今回は Apple Vision Pro でユーザーの位置に合わせてモデルの位置を調整する方法をまとめていきたいと思います。ユーザーの位置に応じた表現は Apple が発表している Encounter Dinosaurs でも使用されているかなと思います。

記事の対象者

  • SwiftUI 学習者
  • Apple Vision Pro の実装について知りたい方

目的

今回の目的は、先述の通り「ユーザーの位置に合わせてモデルの位置を調整する」方法を学ぶことです。
今回は Placing entities using head and device transform のプロジェクトのコードをもとにして、内容を見ていきたいと思います。

実装

実装は以下の手順で進めていきたいと思います。

  1. AppModel の実装
  2. System, Component の実装
  3. View の実装
  4. App の実装

1. AppModel の実装

まずは AppModel の実装を進めていきます。
コードは以下の通りです。

headTrackState では現在選択されているトラッキングのモードを保持しておきます。
follow はユーザーのデバイスの位置に追従するようなモードで、 headPosition はユーザーのデバイスの位置に固定するようなモードにしています。

isImmersiveSpaceOpen では ImmersiveSpace が開いているかどうかを保持するようにしています。

HeadPositionAppModel.swift
import SwiftUI
import RealityKit
import ARKit

@MainActor
@Observable
class HeadPositionAppModel {
    var headTrackState: HeadTrackState = .headPosition
    
    var isImmersiveSpaceOpen: Bool = false
    
    enum HeadTrackState: String, CaseIterable {
        case follow
        case headPosition = "head-position"
    }
}

2. System, Component の実装

次に System の実装を進めていきます。
System については、コードのコメントがわかりやすかったので、日本語訳して引用します。

システム はシーン内の複数のエンティティに影響を与える継続的な動作を表します。システムを使用して、オブジェクトやキャラクターなど、シーンの更新ごとにエンティティを更新する動作やロジックを実装します。
たとえば、物理シミュレーションシステムは、すべてのエンティティに対して重力、力、および衝突の影響を計算して適用します。

今回は「ユーザーの位置に追従してモデルの位置を更新する」という System を作成していきます。
コードは以下の通りです。

import RealityKit
import SwiftUI
import ARKit

public struct FollowSystem: System {
    static let query = EntityQuery(where: .has(FollowComponent.self))
    private let arkitSession = ARKitSession()
    private let worldTrackingProvider = WorldTrackingProvider()
    
    public init(scene: RealityKit.Scene) {
        runSession()
    }
    
    func runSession() {
        Task {
            do {
                try await arkitSession.run([worldTrackingProvider])
            } catch {
                print("Error: \(error). Head position mode will still work.")
            }
        }
    }
    
    public func update(context: SceneUpdateContext) {
        guard worldTrackingProvider.state == .running else { return }
        guard let deviceAnchor = worldTrackingProvider.queryDeviceAnchor(atTimestamp: CACurrentMediaTime()) else {
            return
        }
        
        let deviceTransform = Transform(matrix: deviceAnchor.originFromAnchorTransform)
        let entities = context.entities(matching: Self.query, updatingSystemWhen: .rendering)
        
        for entitiy in entities {
            entitiy.move(to: deviceTransform, relativeTo: entitiy.parent, duration: 1.2, timingFunction: .easeInOut)
        }
    }
}

それぞれ詳しくみていきます。

以下では System を継承した FollowSystem を定義しています。

query では、後述の FollowComponent が付与されている Entity のみを抽出して定義しています。
arkitSession, worldTrackingProvider では、それぞれのインスタンスを保持しています。今回はユーザーの位置を特定して、トラッキングするために ARKitSessionWorldTrackingProvider を使用します。

public struct FollowSystem: System {
    static let query = EntityQuery(where: .has(FollowComponent.self))
    private let arkitSession = ARKitSession()
    private let worldTrackingProvider = WorldTrackingProvider()

以下では、初期化の処理として runSession を実行しています。
runSession メソッドでは、 worldTrackingProvider を含む ARKitSession を実行しています。

public init(scene: RealityKit.Scene) {
    runSession()
}

func runSession() {
    Task {
        do {
            try await arkitSession.run([worldTrackingProvider])
        } catch {
            print("Error: \(error). Head position mode will still work.")
        }
    }
}

以下では、 update メソッドを定義しています。

ユーザーのデバイスの位置は queryDeviceAnchor で取得できます。
取得したデバイスの位置を deviceTransform として定義しています。

entities では、この FollowSystem の対象となる Entity を見つけてきています。
そして、対象となる entities に対して、それぞれ move メソッドでデバイスの位置を指定して移動するように設定しています。

これで、「特定のEntityがユーザーのデバイスの位置に追従して動く」という挙動が実現できます。

public func update(context: SceneUpdateContext) {
    guard worldTrackingProvider.state == .running else { return }
    guard let deviceAnchor = worldTrackingProvider.queryDeviceAnchor(atTimestamp: CACurrentMediaTime()) else {
        return
    }
    
    let deviceTransform = Transform(matrix: deviceAnchor.originFromAnchorTransform)
    let entities = context.entities(matching: Self.query, updatingSystemWhen: .rendering)
    
    for entitiy in entities {
        entitiy.move(to: deviceTransform, relativeTo: entitiy.parent, duration: 1.2, timingFunction: .easeInOut)
    }
}

次に Component の実装を行います。
Component に関してもコードのコメントの文章がわかりやすかったので、日本語訳して引用します。

エンティティに特定の動作や外観を組み合わせるには、EntityインスタンスのEntity/componentsセットにコンポーネントを追加します。それぞれのコンポーネントは、Componentプロトコルに準拠した型によって表され、エンティティの単一の側面を定義します。たとえば、あるコンポーネントは空間内での位置を定義し、別のコンポーネントは視覚的な外観を提供します。エンティティには、特定の型のコンポーネントを1つだけ追加できます。

今回は先ほど実装した FollowSystem の対象となる Entity を区別するために FollowComponent を使用します。
コードは以下の通りです。 Component, Codable を継承したシンプルなものになっており、特に内部の実装は必要ありません。あくまで FollowSystem の対象になっているものに付与して区別するという目的で使用します。

FollowComponent.swift
import RealityKit
import SwiftUI
import ARKit

public struct FollowComponent: Component, Codable {}

これで System と Component の実装は完了です。

3. View の実装

次に View の実装に移ります。
View はモードを切り替える TogglePanel と、モデルを表示させる FollowImmersiveView の二つを実装していきます。

まずは TogglePanel の実装を行います。
コードは以下の通りです。

import SwiftUI

struct TogglePanel: View {
    @Environment(HeadPositionAppModel.self) private var appModel
    @Environment(\.openImmersiveSpace) var openImmersiveSpace
    @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace
    
    var body: some View {
        @Bindable var appModel = appModel
        Group {
            if !appModel.isImmersiveSpaceOpen {
                Button("Launch Airplane") {
                    Task {
                        await openImmersiveSpace(id: "FollowImmersiveView")
                        appModel.isImmersiveSpaceOpen = true
                    }
                }
            } else {
                VStack {
                    Spacer()
                    Text("Currently enabled: \(appModel.headTrackState.rawValue.capitalized) mode")
                    Picker("Current state", selection: $appModel.headTrackState) {
                        ForEach(HeadPositionAppModel.HeadTrackState.allCases, id: \.self) { state in
                            Text("\(state.rawValue.capitalized) mode")
                        }
                    }
                    .pickerStyle(SegmentedPickerStyle())
                    
                    Spacer()
                    Button("Exit immersive space") {
                        Task {
                            await dismissImmersiveSpace()
                            appModel.isImmersiveSpaceOpen = false
                        }
                    }
                }
                .padding()
            }
        }
    }
}

それぞれ詳しくてみていきます。

以下では、 TogglePanel に必要な変数の定義をしています。
appModel では HeadPositionAppModel を定義しています。
openImmersiveSpace, dismissImmersiveSpace では ImmersiveSpace を開いたり閉じたりする処理を定義しています。

struct TogglePanel: View {
    @Environment(HeadPositionAppModel.self) private var appModel
    @Environment(\.openImmersiveSpace) var openImmersiveSpace
    @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace

以下では、 ImmersiveSpace が開いていない場合の View を定義しています。
ImmersiveSpace が開いていない場合は Button を表示して、FollowImmersiveView のスペースを開くようにしています。

var body: some View {
    @Bindable var appModel = appModel
    Group {
        if !appModel.isImmersiveSpaceOpen {
            Button("Launch Airplane") {
                Task {
                    await openImmersiveSpace(id: "FollowImmersiveView")
                    appModel.isImmersiveSpaceOpen = true
                }
            }
        } 

以下では、ImmersiveSpace が開いている場合のViewを定義しています。
モデルを表示させるモードの HeadTrackState を切り替えるための Picker や、スペースを閉じるための Button を表示させています。

} else {
    VStack {
        Spacer()
        Text("Currently enabled: \(appModel.headTrackState.rawValue.capitalized) mode")
        Picker("Current state", selection: $appModel.headTrackState) {
            ForEach(HeadPositionAppModel.HeadTrackState.allCases, id: \.self) { state in
                Text("\(state.rawValue.capitalized) mode")
            }
        }
        .pickerStyle(SegmentedPickerStyle())
        
        Spacer()
        Button("Exit immersive space") {
            Task {
                await dismissImmersiveSpace()
                appModel.isImmersiveSpaceOpen = false
            }
        }
    }
    .padding()
}

次に FollowImmersiveView の実装を行います。
コードは以下の通りです。

import SwiftUI
import RealityKit
import RealityKitContent
import ARKit

struct FollowImmersiveView: View {
    @Environment(HeadPositionAppModel.self) private var appModel
    let followRoot: Entity = Entity()
    let headAnchorRoot: Entity = Entity()
    let headPositionedEntitiesRoot: Entity = Entity()
    let target: Entity = Entity()
    
    var body: some View {
        RealityView { content in
            do {
                let targetContent = try await Entity(named: "ToyBiplane", in: realityKitContentBundle)
                targetContent.scale = SIMD3<Float>(x: 1, y: 1, z: 1)
                target.addChild(targetContent)
                
                followRoot.components.set(FollowComponent())
                content.add(followRoot)
                content.add(headAnchorRoot)
                startHeadPositionMode(content: content)
            } catch {
                fatalError("No entity to load")
            }
        } update: { content in
            toggleMode(content: content)
        }
        .onDisappear {
            if let targetContent = target.children.first {
                target.removeChild(targetContent)
            }
        }
    }
}

extension FollowImmersiveView {
    func startFollowMode() {
        guard let headAnchor = headAnchorRoot.children.first(where: { $0.name == "headAnchor" }) else { return }
        headAnchorRoot.removeChild(headAnchor)
        
        followRoot.setPosition([0, 1, -1], relativeTo: nil)
        
        let orientation = simd_quatf(angle: .pi * -0.15, axis: [0, 1, 0]) * simd_quatf(angle: .pi * 0.2, axis: [1, 0, 0])
        target.transform.rotation = orientation
        
        followRoot.addChild(target)
        target.setPosition([0.4, 0.2, -1], relativeTo: followRoot)
    }
    
    func startHeadPositionMode(content: RealityViewContent) {
        target.transform.rotation = simd_quatf()
        
        let headAnchor = AnchorEntity(.head)
        headAnchor.anchoring.trackingMode = .once
        headAnchor.name = "headAnchor"
        headAnchorRoot.addChild(headAnchor)
        
        headPositionedEntitiesRoot.addChild(target)
        target.setPosition([0, 0, -0.15], relativeTo: headPositionedEntitiesRoot)
        
        headAnchor.addChild(headPositionedEntitiesRoot)
        headPositionedEntitiesRoot.setPosition([0, 0, -0.6], relativeTo: headAnchor)
    }
    
    func toggleMode(content: RealityViewContent) {
        switch appModel.headTrackState {
        case .follow:
            startFollowMode()
        case .headPosition:
            startHeadPositionMode(content: content)
        }
    }
}

それぞれ詳しくみていきます。

以下では、 FollowImmersiveView に必要な変数の定義を行なっています。
appModel では、HeadPositionAppModel を受け取るようにしています。
followRoot では、追従するモデルのルートとなる Entity を定義しています。
headAnchorRoot では、ユーザーのデバイスの位置を保持するルートとなる Entity を定義しています。
headPositionedEntitiesRoot では、ユーザーのデバイスの位置にモデルを固定する場合の、デバイスの位置を保持する Entity を定義しています。
target では、ユーザーの位置を追従するモデル自体の Entity を保持するようにしています。

struct FollowImmersiveView: View {
    @Environment(HeadPositionAppModel.self) private var appModel
    let followRoot: Entity = Entity()
    let headAnchorRoot: Entity = Entity()
    let headPositionedEntitiesRoot: Entity = Entity()
    let target: Entity = Entity()

以下では、FollowImmersiveViewbody として RealityView を指定しています。
ToyBiplane というモデルを読み込んで、 targetContent に代入しています。
モデルは必要に応じて大きさを調整して、 target.addChild メソッドで、 target の子要素としてシーンに追加されます。

followRoot.components.set メソッドで FollowComponent を渡すことで、 FollowSystem の対象として区別することができるようになります。

startHeadPositionMode ではユーザーの頭の位置にモデルを固定するモードを開始しています。詳細については後述します。

var body: some View {
    RealityView { content in
        do {
            let targetContent = try await Entity(named: "ToyBiplane", in: realityKitContentBundle)
            targetContent.scale = SIMD3<Float>(x: 1, y: 1, z: 1)
            target.addChild(targetContent)
            
            followRoot.components.set(FollowComponent())
            content.add(followRoot)
            content.add(headAnchorRoot)
            startHeadPositionMode(content: content)
        } catch {
            fatalError("No entity to load")
        }

以下では、 updatetoggleMode を指定することで、モードの切り替えを監視するようにしています。

} update: { content in
    toggleMode(content: content)
}

以下では onDisappear メソッドで、スペースが破棄された際に targetContent を破棄しています。

.onDisappear {
    if let targetContent = target.children.first {
        target.removeChild(targetContent)
    }
}

以下では、 startFollowMode メソッドを実装しています。
このメソッドが実行されると、モデルがユーザーの位置に追従するモードに移行します。

このメソッドでは以下のステップで処理が行われています。
詳しくはコメントの通りです。

  1. 不要なアンカーの削除 : 頭部アンカーを削除し、シーンを整理。
  2. followRoot の配置 : 視界の前方に followRoot を配置。
  3. ToyBiplane の向き調整 : 自然な視点で表示されるように回転。
  4. 階層構造の設定 : followRoot の子として ToyBiplane を追加し、位置を設定。
extension FollowImmersiveView {
    func startFollowMode() {
        // headAnchor は FollowMode では使用しないため、アンカーを削除
        guard let headAnchor = headAnchorRoot.children.first(where: { $0.name == "headAnchor" }) else { return }
        headAnchorRoot.removeChild(headAnchor)

        // ユーザーの視界の前方に followRoot を配置
        followRoot.setPosition([0, 1, -1], relativeTo: nil)

        // モデルの回転を調節して、良い向きで表示されるように変更
        let orientation = simd_quatf(angle: .pi * -0.15, axis: [0, 1, 0]) * simd_quatf(angle: .pi * 0.2, axis: [1, 0, 0])
        target.transform.rotation = orientation

        // followRoot の子要素に target を追加
        // followRoot には FollowComponent が付与されているため、FollowSystem の挙動に従うようになる
        followRoot.addChild(target)

        // target の位置を followRoot の位置と相対的に決定
        target.setPosition([0.4, 0.2, -1], relativeTo: followRoot)
    }

簡単に図にすると以下のようになるかなと思います。

followRoot はユーザーの視界の前にあり、 FollowSystem に従っているため、ユーザーの動きに応じて位置が変化する

targetfollowRoot の子要素として定義されているため、 followRoot と同じように移動する。target の位置は followRoot に対して相対的な位置に設定されている

以下では、モデルをユーザーの位置に固定する場合の startHeadPositionMode を実装しています。
AnchorEntity(.head) で頭の位置を取得できます。一度だけ位置を取得したら、そのアンカーを headAnchorRoot の子要素として追加しています。

headPositionedEntitiesRoot の子要素に target を指定し、位置を調整したのち、その headPositionedEntitiesRootheadAnchor の子要素に指定しています。
これで、頭の位置のアンカーにモデルを表示できるようになります。

func startHeadPositionMode(content: RealityViewContent) {
    target.transform.rotation = simd_quatf()
    
    let headAnchor = AnchorEntity(.head)
    headAnchor.anchoring.trackingMode = .once
    headAnchor.name = "headAnchor"
    headAnchorRoot.addChild(headAnchor)
    
    headPositionedEntitiesRoot.addChild(target)
    target.setPosition([0, 0, -0.15], relativeTo: headPositionedEntitiesRoot)
    
    headAnchor.addChild(headPositionedEntitiesRoot)
    headPositionedEntitiesRoot.setPosition([0, 0, -0.6], relativeTo: headAnchor)
}

以下では、モデルを表示させるモードの切り替えを行なっています。
appModel に定義されているモードを切り替え、それと同時に切り替えたモードをスタートするようにしています。

func toggleMode(content: RealityViewContent) {
    switch appModel.headTrackState {
    case .follow:
        startFollowMode()
    case .headPosition:
        startHeadPositionMode(content: content)
    }
}

これで、View の実装は完了です。

4. App の実装

次に App の実装を行います。

コードは以下の通りです。
基本的には、先ほど実装した TogglePanelFollowImmersiveView を表示できるように設定しているだけです。
ただ、 init メソッドの中で FollowSystemFollowComponent の設定を行なっています。 System や Component はこのように使用する前に登録しておく必要があります。

HeadPositionApp.swift
import SwiftUI

@main
struct HeadPositionApp: App {
    @State private var appModel: HeadPositionAppModel = HeadPositionAppModel()
    @Environment(\.openImmersiveSpace) var openImmersiveSpace
    
    init() {
        FollowSystem.registerSystem()
        FollowComponent.registerComponent()
    }
    
    var body: some Scene {
        WindowGroup {
            TogglePanel()
                .environment(appModel)
        }
        .defaultSize(CGSize(width: 400, height: 200))
        
        ImmersiveSpace(id: "FollowImmersiveView") {
            FollowImmersiveView()
                .environment(appModel)
        }
    }
}

これで実行すると、以下のページにあるように、ユーザーの頭の位置に合わせてモデルの位置が変わることがわかります。

https://developer.apple.com/documentation/visionos/placing-entities-using-head-and-device-transform

まとめ

最後まで読んでいただいてありがとうございました。

決まったアニメーションを実行する場合は今回のような実装は必要ありませんが、ユーザーの位置に合わせたアニメーションを実行することで、より没入感のある体験が実現できるかと思います。

誤っている点やもっと良い書き方があればご指摘いただければ幸いです。

参考

https://developer.apple.com/documentation/visionos/placing-entities-using-head-and-device-transform

Discussion