👓

【Swift】Vision OS で自作のオブジェクトを配置してみる

2023/11/12に公開

初めに

最近はずっと Flutter を使っていたため、Swift、SwiftUI の復習を兼ねて Vision OS を動かしてみたいと思います。今回はオブジェクトを配置するまで実装していきたいと思います。

記事の対象者

  • Swift, SwiftUI 学習者
  • Vision OS に触れてみたい方

実装

今回は Blender で制作した3Dモデルを取り込むところからやってみたいと思います。
具体的な手順は以下の通りです。

  1. Blender から Reality Converter へエクスポート
  2. Reality Converter からプロジェクトへエクスポート
  3. Vision OS プロジェクトの作成
  4. App の設定
  5. ContentView の作成
  6. モデルを表示させる画面の作成
  7. 実行結果

Blender から Reality Converter へエクスポート

まずは Vision OS で表示させたい3Dモデルの Blender のモデルのプロジェクトを開きます。今回は以下の飛行機のモデルを取り込んでみます。

次にモデルのエクスポートを行います。
画像のように、File > Export > glTF2.0(.glb/.gltf) を選択してエクスポートします。なお、この際ウィンドウの右側でエクスポートの設定が可能です。エクスポートする際にモディファイアが含まれないことがあるので、Geometry > Mesh > Apply Modifiers の部分にチェックを入れておいた方が良いかと思います。

次に Reality Converter へのインポートを行います。
以下のような表示の部分に先程エクスポートしたファイルをドラッグ&ドロップするだけでインポートは完了します。

完了すると以下のようにインポートしたモデルが表示されるようになります。

Reality Converter からプロジェクトへエクスポート

モデルが正常にインポートされていることが確認できたら以下の画像のように、右上のボタンから「書き出す」を選択して、USDZ形式でエクスポートします。

次に先程エクスポートしたファイルをプロジェクト内にドラッグ&ドロップしてプロジェクトでモデルを使用できるようになります。

Vision OS プロジェクトの作成

ここから Xcode を用いた実装に移っていきます。
Xcode の「Create Project」で以下の画面に移り、「visionOS」タブを選択して「App」を選択して「Next」を押します。

次に以下の画面に移るのでプロジェクトの名前を設定します。
なお、Initial Screen は「Window」に設定しておきます。
保存先のパスを指定してプロジェクトの作成は完了です。

App の設定

App のコードの全文は以下のようになります。

SampleApp
import SwiftUI

@main
struct SampleApp: App {
    var body: some Scene {
        WindowGroup(id: "Main") {
            ContentView()
        }
    }
}

VisionOS のプロジェクトは基本的に SwiftUI に基づいているため、SwiftUI をインポートしてビューを構築していきます。
この辺りのコードの記述方法は通常の iOS のアプリケーションと全く同じであると言えると思います。

ContentView の作成

次に ContentView の実装を行います。
コードの全文は以下のようになります。

ContentView
import SwiftUI
import RealityKit
import RealityKitContent

struct ContentView: View {
    @Environment(\.openImmersiveSpace) var openImmersiveSpace
    @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace

    var body: some View {
        NavigationSplitView {
            List {
                Text("Item List")
            }
            .navigationTitle("Hello World !")
        } detail: {
            ScrollView() {
                LazyVGrid(
                    columns: Array(repeating: .init(.flexible()), count: 3),
                    alignment: .center,
                    spacing: 4
                ) {
                    Image("airplane")
                        .resizable()
                        .scaledToFill()
                        .onTapGesture {
                            Task {
                                await openImmersiveSpace(id: "airplane")
                        }
                    }
                }
            }
            .navigationTitle("Content")
            .padding()
        }
    }
}

#Preview {
    ContentView()
}

ContentView の見た目は以下のようになります。

以下の部分では @Environment を用いて、ImmersiveSpace を開く、閉じるための処理をそれぞれ変数として定義しています。

ContentView
    @Environment(\.openImmersiveSpace) var openImmersiveSpace
    @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace

以下の部分では NavigationSplitView を実装しています。

ContentView
        NavigationSplitView {
            List {
                Text("Item List")
            }
            .navigationTitle("Hello World !")
        } detail: {
            ScrollView() {
                LazyVGrid(
                    columns: Array(repeating: .init(.flexible()), count: 3),
                    alignment: .center,
                    spacing: 4
                ) {
                    Image("airplane")
                        .resizable()
                        .scaledToFill()
                        .onTapGesture {
                            Task {
                                await openImmersiveSpace(id: "airplane")
                        }
                    }
                }
            }
            .navigationTitle("Content")
            .padding()
        }

NavigationSplitView では detail に要素を置くことで、左右に分かれた表示をさせることができます。
detail の要素の一つとして画像を表示しており、それをタップすると openImmersiveSpace が非同期処理で発火するようになっています。
なお、今はまだ id が airplane の ImmersiveSpace を設定していないため、エラーが出力されます。

airplane の ImmersiveSpace は以下のように App のコードに追加することでボタンをタップした際に開けるようになります。

SampleApp
import SwiftUI

@main
struct SampleApp: App {
    var body: some Scene {
        WindowGroup(id: "Main") {
            ContentView()
        }
+       ImmersiveSpace(id: "airplane") {
+           AirplaneView()
+       }
    }
}

モデルを表示させる画面の作成

最後にモデルを表示させるための AirplaneView を作成します。
コードは以下のようになっています。

AirplaneView
import SwiftUI
import RealityKit
import RealityKitContent

struct AirplaneView: View {
    @Environment(\.openImmersiveSpace) var openImmersiveSpace
    @Environment(\.dismissWindow) var dismissWindow

    var body: some View {
        VStack {
            RealityView { content in
                if let immersiveContentEntity = try? await Entity(named: "AirplaneScene", in: realityKitContentBundle) {
                    content.add(immersiveContentEntity)
                }
            }
            .onAppear {
                dismissWindow(id: "Main")
            }
        }
    }
}

#Preview {
    AirplaneView()
}

RealityView は Reality Composer Pro で作成された RealityKit コンテンツなどを visionOS アプリで 3D コンテンツを表示するために使用します。
RealityView の中でコンテンツを定義し、content.add としてコンテンツを追加していくことができます。

なお、airplane というモデルをインポートしましたが、ここで読み込んでいるのは AirplaneScene というモデルです。
この AirplaneScene は先程インポートした airplane モデルの座標を変更したものです。
以下で詳しく解説します。

AirplaneScene を Reality Composer Pro で開くと以下のようになっています。

position の部分が 0, 150, -150 になっています。これは Vision OS の座標の原点(0, 0, 0) がユーザーの足元にあることに起因しています。
y軸は3D空間における高さ、z軸は奥行きを示しているため、0, 150, -150 という座標は、ユーザーの足元から高さ 150cm、奥側に 150cm の位置になります。

実行結果

最後に実行してみた結果は以下のようになります。
GIF に変換してアップロードしようとしましたが、容量が大きすぎたので、Dropbox で共有します。
こちらからご覧ください。

なお、画像のみだと以下のようになります。

ContentView


AirplaneView


他の角度から観察

まとめ

最後まで読んでいただいてありがとうございました。
今回は単にオブジェクトを表示させるだけでしたが、これからどんどんオブジェクトを追加したり、オブジェクトを動かせるようにしていきたいと思います。
あと、個人的に重たい動画を Zenn に掲載する際の良い方法があればご教授いただけると嬉しいです。

Discussion