VisionProでVolumesとImmersiveSpaceを行き来するアプリを試してみよう

# この記事の内容は？
[WWDC24](https://developer.apple.com/videos/play/wwdc2024/10153)のセッションの内容を試して以下の動画のようなアプリを実装した記事になっています。
![Simulator Screen Recording - Apple Vision Pro - 2024-12-22 at 22.13.30tri.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2959874/9c477d89-ed50-68ea-313a-6eb4afd2dc77.gif)


基本的にはWWDC24の内容の通りやった形ですが、アニメーション周りの準備に関して少しだけ追記をしています（が、記事を間に合わせようと力技でやったので、あんまり良い方法ではないと思います）。

# はじめに
[visionOS Advent Calendar 2024 22日目](https://qiita.com/advent-calendar/2024/visionos)の記事です。
こんにちは、こうたと言います。

今回は、[WWDC24](https://developer.apple.com/videos/play/wwdc2024/10153)の内容をもとに、３DキャラクターをUIの世界から、私たちの世界に召喚してみようと思います！

# 何をしているか？
UI的な表現と没入的な表現を行き来することで、キャラクターがまるで自分たちの元にきてくれるような生き生きとした表現を実現しています。

具体的には、Volume とImmersive Spaceの二つのシーンタイプを行き来することで演出をしています。

Volumeは、限られた空間内に3Dオブジェクトを描画できるシーンとなっています。こちらを使うことで、箱庭的な空間にキャラクターがいる表現を実現できます。
そして、Immersive Spaceは、用意した3D空間に入ることができるので、より没入的な空間で目の前までキャラクターが来てくれるような感覚を表現できます。

# 具体的な方法
## シーンの用意
まずは、VolumeとImmersiveの両空間を用意します

``` swift
@main
struct ImmersiveCharactorApp: App {
    @State private var appModel = AppModel()

    var body: some Scene {
        WindowGroup {
            VolumeticView()
                .ornament(attachmentAnchor: .scene(.topBack)) {
                    OrnamentView()
                        .environment(appModel)
                }
                .environment(appModel)
        }
        .windowStyle(.volumetric)

        ImmersiveSpace(id: appModel.immersiveSpaceID) {
            ImmersiveView()
                .environment(appModel)
                .onAppear {
                    appModel.immersiveSpaceState = .open
                }
                .onDisappear {
                    appModel.immersiveSpaceState = .closed
                }
        }
        .immersionStyle(selection: .constant(.progressive), in: .progressive)
    }
}
```

そして、以下で空間の切り替えを制御してます。
VolumeのOrnamentにButtonを配置し、Immersive空間のON/OFFを制御できるようにしています。


```swift
struct OrnamentView: View {

    @Environment(AppModel.self) private var appModel
    @Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace
    @Environment(\.openImmersiveSpace) private var openImmersiveSpace

    var body: some View {
        VStack {
            Button {
                Task { @MainActor in
                    switch appModel.immersiveSpaceState {
                        case .open:
                            appModel.immersiveSpaceState = .inTransition
                            await dismissImmersiveSpace()

                        case .closed:
                            appModel.immersiveSpaceState = .inTransition
                            switch await openImmersiveSpace(id: appModel.immersiveSpaceID) {
                                case .opened:
                                    break

                                case .userCancelled, .error:
                                    fallthrough
                                @unknown default:
                                    appModel.immersiveSpaceState = .closed
                            }

                        case .inTransition:
                            break
                    }
                }
            }
        }
    }
...
```

```swift
@MainActor
@Observable
class AppModel {
    let immersiveSpaceID = "ImmersiveSpace"
    enum ImmersiveSpaceState {
        case closed
        case inTransition
        case open
    }
    var immersiveSpaceState = ImmersiveSpaceState.closed
...
```

## 空間の行き来について
Immersive空間を表示できるようになりました。
次に、VolumeのキャラクターをImmersive空間に移動させたいと思います。
[WWDC2024の20:52](https://developer.apple.com/videos/play/wwdc2024/10153?time=1252)で具体的な解説がされているので、ここでは具体的な解説は割愛します。
内容としては、Volumeの空間でアフィン変換行列を取得し、そちらを利用してImmersive空間に写像しています。

```swift
struct VolumeticView: View {
    @Environment(AppModel.self) private var appModel
    
    var body: some View {
        RealityView { content in
            if let scene = try? await Entity(named: "Volumetirc", in: realityKitContentBundle) {
                content.add(scene)
                appModel.content = content
                appModel.setCharactor(entity: scene);
            }
        } update: { content in
            guard appModel.convertingRobotFromVolume else { return }

            appModel.convertRobotFromRealityKitToImmersiveSpace(content: content)
        }
    }
}
```

```swift
struct ImmersiveView: View {
    @Environment(AppModel.self) var appModel

    var body: some View {
        RealityView { content in
            if let immersiveContentEntity = try? await Entity(named: "Immersive", in: realityKitContentBundle) {
                content.add(immersiveContentEntity)
            }
            content.add(appModel.immersiveSpaceRoot)
            
            // Immersiveを表示した時にONにしている
            // appModel内で制御したいが、解説用にここで制御
            appModel.convertingRobotFromVolume = true
            appModel.content = content
        } update: { content in
            guard appModel.convertingRobotToImmersiveSpace else { return }
            
            appModel.convertRobotFromSwiftUIToRealityKitSpace(content: content)
        }
    }
```

```swift
    func convertRobotFromRealityKitToImmersiveSpace(content: RealityViewContent) {
        immersiveSpaceFromRobot =
        content.transform(from: charactor, to: .immersiveSpace)
        
        charactor.setParent(immersiveSpaceRoot)
        
        convertingRobotFromVolume = false
        convertingRobotToImmersiveSpace = true
    }
    
    func convertRobotFromSwiftUIToRealityKitSpace(content: RealityViewContent) {
        let realityKitSceneFromImmersiveSpace =
        content.transform(from: .immersiveSpace, to: .scene)
        

        let realityKitSceneFromRobot =
        realityKitSceneFromImmersiveSpace * immersiveSpaceFromRobot
        

        charactor.transform = Transform(realityKitSceneFromRobot)
        convertingRobotToImmersiveSpace = false

        startJump()
    }
```

## Immersive空間で没入感のある表現の追加
VisionOS2.0以降で、`onImmersionChange`を使うことでDegitalCrownによるImmersive度の変更を取れるようになりました。
こちらをつかうことで、没入度が増えた時にこちら側へ向かってくる表現をすることでより没入感を増した表現ができます。
こちらも、[WWDCのビデオ](https://developer.apple.com/videos/play/wwdc2024/10153/?time=1517)で解説されているので詳細は割愛します。

```swift
struct ImmersiveView: View {
    @Environment(AppModel.self) var appModel
    @State var immersionAmount: Double?

    var body: some View {
        RealityView { content in
            ...
        } update: { content in
            ...
        }
        .onImmersionChange { oldContext, newContext in
            immersionAmount = newContext.amount
        }
        .onChange(of: immersionAmount) { oldValue, newValue in
            handleImmersionAmountChanged(newValue: newValue, oldValue: oldValue)
        }
    }
    func handleImmersionAmountChanged(newValue: Double?, oldValue: Double?) {
        guard let newValue, let oldValue else {
            return
        }

        if newValue > oldValue {
            appModel.moveCharactorOutward()
        } else if newValue < oldValue {
            appModel.moveCharactorInward()
        }
    }
...
```

# キャラクターのアニメーションについて
WWDCでは、その他の様々な表現が紹介されており、それらを活用することでアプリに没入感を足すことが足せそうです。
これらの表現をコードで制御することができた一方で、アニメーションも生き生きとした表現には欠かせません。こちらの準備については、解説がないので、自分が用意した方法を軽く触れたいと思います

## アニメーションの準備
まず、キャラクター用のアニメーションの用意をします。
[Mixamo](https://www.mixamo.com/)には豊富なアニメーションが用意されており、個人的にはおすすめです。もしくは、BoothやUnityAssetStoreなどでもアニメーションが存在しているので探してみるのも良さそうです。
## アニメーションの変換
VisionProで扱うためには、USDやUSDZといったフォーマットにする必要があります。
[Reality Converter](https://developer.a'apple.com/jp/news/?id=01132020a)を使うと、お手軽に変換できるので、こちらで用意したFBXなどを変換して扱うことが可能になります。
もしくは、Unityでも[変換用のパッケージ](https://github.com/Unity-Technologies/usd-unity-sdk)があり、こちらでも変換が手軽にできます。

## コード上でのアニメーションの制御
VisionOS2.0以降では[AnimationLibrary](https://developer.apple.com/documentation/realitykit/animationlibrarycomponent)を使うことで、Entityのアニメーションにアクセスし、書き出しなどを行うことができるようになります。
たとえば、Animationをあるディレクトリにまとめておき、読み取りまとめて書き出すこともできます（下記コードは実行できてないので動くかは保証できてません）。Entityインスタンスをそのまま活用しても良さそうです。
読み込んだアニメーションはEntity.playAnimationで再生できます。

```swift
if let charactor = try? await Entity(named: "Volumetirc", in: realityKitContentBundle) {
    let animationDirectory = "animations/\(アニメーションのUSDZの名前)"
    if let rootEntity = try? await Entity(named: animationDirectory, in: realityKitContentBundle) {
        if let _entity = rootEntity.findEntity(named: "アニメーションがあるEntityの名前") {
            if let animationLibraryComponent = await _entity.components[AnimationLibraryComponent.self] {
                guard var _animationLibrary = entity.components[AnimationLibraryComponent.self] else { return }
                
                _animationLibrary.animations[animationName.rawValue] = animationLibraryComponent.defaultAnimation
            }
        }
    }
    charactor.components.set(animationLibrary)
    
    charactor.write(to: fileURL)
}
```

# 最後に
WWDCの内容を活用して、3Dキャラクターを没入的に登場させるやり方を試してみました。
VisionOS2.0からAPIが増えて、さまざまな情報をとることができるようになり、インタラクティブな表現が試せるようになったことを感じられます。
アニメーションの設定の時間が足りず途中になってしまったのですが、それでも飛び出してくるような表現があると臨場感を感じることができました。
WWDCにはたくさんのビデオが公開されているので、他のセッションも是非とも試してみたいところです。

# 使用アセット
3Dキャラクター：猫山苗(販売終了)
待機モーション：マヌカ(https://booth.pm/ja/items/5058077)![アップロードできるファイル容量は各10MBまでです。]()