🏎️

【Swift】visionOS で3Dモデルをユーザーが動かせるようにする

に公開

初めに

今回は Apple Vision Pro でユーザーが3Dモデルを自由に動かせるような機能を実装していきたいと思います。具体的には、ユーザーの手の動きによってモデルの大きさや向きを変更できるようにしていきます。

記事の対象者

  • Swift, SwiftUI 学習者
  • visionOS の実装をしてみたい方

目的

今回は上記の通り、ユーザーが自由にモデルを動かせるような実装を行うことが目的です。
最終的には以下の動画のようにユーザーのハンドジェスチャーで自由にモデルを動かせるようにしたいと思います。

https://youtu.be/y9HdHak_GtU

実装

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

  1. モデルを表示する
  2. モデルを動かせるように変更

1. モデルを表示する

まずはモデルを表示してみます。
コードは以下の通りです。

import SwiftUI
import RealityKit
import RealityKitContent

struct BimanualPinchAndTransformView: View {
    
    @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace
    
    @PhysicalMetric(from: .meters) private var zOffsetOfModel = -2.5
    @PhysicalMetric(from: .meters) private var yOffsetOfModel = -1
    @PhysicalMetric(from: .meters) private var xOffsetOfCard = 0.5
    @PhysicalMetric(from: .meters) private var yOffsetOfCard = -1.5
    @PhysicalMetric(from: .meters) private var zOffsetOfCard = -2.1
    
    var body: some View {
        Model3D(named: "CarScene", bundle: realityKitContentBundle) { model in
            model
                .resizable()
                .scaledToFit()
                .rotation3DEffect(.degrees(90), axis: .y)
                .offset(y: yOffsetOfModel)
                .offset(z: zOffsetOfModel)
                .overlay {
                    VStack(spacing: 16) {
                        Text("Toyota FT-1 Vision GT")
                            .font(.largeTitle)
                        Text("The Toyota FT-1 Vision Gran Turismo is a Vision Gran Turismo concept car made by Toyota. It appears in Gran Turismo 6 (as part of Update 1.12), Gran Turismo Sport, and Gran Turismo 7. It is a racing version of the Toyota FT-1, with features and designs required for pure racing competition.\n\nModel : https://skfb.ly/oQqMS\nReference : https://gran-turismo.fandom.com/wiki/Toyota_FT-1_Vision_Gran_Turismo")
                            .frame(width: 500)
                        Button(action: {
                            Task {
                                await dismissImmersiveSpace()
                            }
                        }, label: {
                            Text("閉じる")
                                .padding()
                        })
                    }
                    .padding(50)
                    .glassBackgroundEffect()
                    .offset(x: xOffsetOfCard, y: yOffsetOfCard)
                    .offset(z: zOffsetOfCard)
                }
        } placeholder: {
            ProgressView()
        }
    }
}

CarScene の部分で表示させたいモデルの名前を指定して、上記のコードを実行すると以下の画像のようにモデルと説明文が表示されるようになります。筆者の手元では車のモデルと説明文を用意しています。現状ではモデルを動かそうとしても動かないかと思います。

それぞれのコードを詳しくみていきます。

以下では、dismissImmersiveSpace として現在開いている ImmersiveSpace を閉じるためのメソッドを用意しています。

@Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace

以下ではモデルと説明文を表示するためのカードの位置を設定しています。
個人的には、モデルなどの位置を設定する際にマジックナンバーにならないようにあらかじめ定義しておくと良いかなと思います。
以下の設定では、モデルがユーザーの前に表示され、説明文を含むカードがモデルの右上に表示されるようにしています。

@PhysicalMetric(from: .meters) private var zOffsetOfModel = -2.5
@PhysicalMetric(from: .meters) private var yOffsetOfModel = -1
@PhysicalMetric(from: .meters) private var xOffsetOfCard = 0.5
@PhysicalMetric(from: .meters) private var yOffsetOfCard = -1.5
@PhysicalMetric(from: .meters) private var zOffsetOfCard = -2.1

以下では CarScene というモデルを読み込んで表示させています。
読み込んだ model に対して、 resizable を付与することで、親のビューのレイアウト制約に従ってサイズが調整されます。
scaledToFit を付与することで、モデルのアスペクト比を維持しながら、可能な限り大きく表示されます。これでモデルが歪むなどの問題が発生しなくなります。
rotation3DEffect ではモデルを90度回転させて、初期段階では右側を向くように設定しています。
offset では先程設定していた zOffsetOfModel, yOffsetOfModel を使ってモデルの位置を調整しています。

var body: some View {
    Model3D(named: "CarScene", bundle: realityKitContentBundle) { model in
        model
            .resizable()
            .scaledToFit()
            .rotation3DEffect(.degrees(90), axis: .y)
            .offset(y: yOffsetOfModel)
            .offset(z: zOffsetOfModel)

以下では overlay を使ってモデルの説明文を表示させています。
「閉じる」ボタンを押すと dismissImmersiveSpace で ImmersiveSpace が閉じるようにしています。
offset ではモデルと同様で、設定していた xOffsetOfCard, yOffsetOfCard, zOffsetOfCard を使って、説明文のカードの位置を調整しています。

.overlay {
    VStack(spacing: 16) {
        Text("Toyota FT-1 Vision GT")
            .font(.largeTitle)
        Text("説明文 ... ")  // 省略
            .frame(width: 500)
        Button(action: {
            Task {
                await dismissImmersiveSpace()
            }
        }, label: {
            Text("閉じる")
                .padding()
        })
    }
    .padding(50)
    .glassBackgroundEffect()
    .offset(x: xOffsetOfCard, y: yOffsetOfCard)
    .offset(z: zOffsetOfCard)
}

これでモデルの表示はできました。

2. モデルを動かせるように変更

次にモデルを自由に動かせるように設定していきます。
この実装は以下の手順で進めていきます。

  1. 拡大、回転などのジェスチャーを受け取る
  2. ジェスチャーの反映

1. 拡大、回転などのジェスチャーを受け取る

まずはユーザーがモデルを拡大したり回転させたりしたことを検知できるようにします。
以下のようにコードを追加します。

import SwiftUI
import RealityKit
import RealityKitContent

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

    @PhysicalMetric(from: .meters) private var zOffsetOfModel = -2.5
    // モデルやカードのその他の初期位置設定(省略 ...)

+   var bimanualPinchAndTransformGesture: some Gesture<AffineTransform3D> {
+       DragGesture()
+           .simultaneously(with: MagnifyGesture())
+           .simultaneously(with: RotateGesture3D())
+           .map { gesture in
+               let (translation, scale, rotation) = gesture.components()
+               return AffineTransform3D(
+                   scale: scale,
+                   rotation: rotation,
+                   translation: translation
+               )
+           }
+   }

    var body: some View {
        Model3D(named: "CarScene", bundle: realityKitContentBundle) { model in
            model
                .resizable()
                // モデル、説明文を含むカードの設定(省略 ...)
        } placeholder: {
            ProgressView()
        }
    }
}

+ extension SimultaneousGesture<
+     SimultaneousGesture<DragGesture, MagnifyGesture>,
+     RotateGesture3D>.Value {
+     func components() -> (Vector3D, Size3D, Rotation3D) {
+         let translation = self.first?.first?.translation3D ?? .zero
+         let magnification = self.first?.second?.magnification ?? 1
+         let size = Size3D(width: magnification, height: magnification, depth: magnification)
+         let rotation = self.second?.rotation ?? .identity
+         return (translation, size, rotation)
+     }
+ }

追加部分について詳しくみていきます。

以下では、ユーザーのジェスチャーから拡大や回転などを取得して、AffineTransform3D に変換して返すようなジェスチャーを設定しています。
DragGesture() ではユーザーのドラッグジェスチャーを取得しています。
また、simultaneouslyMagnifyGestureRotateGesture3D を入れることでドラッグジェスチャーと同時に拡大縮小のジェスチャー(MagnifyGesture)と回転のジェスチャー(RotateGesture3D)を取得しています。
そして、取得したジェスチャーをもとに gesture.components() を実行することでモデルの位置(translation)、大きさ(scale)、回転(rotation)を取得しています。
最後にそれらを AffineTransform3D に当てはめて返却しています。

var bimanualPinchAndTransformGesture: some Gesture<AffineTransform3D> {
    DragGesture()
        .simultaneously(with: MagnifyGesture())
        .simultaneously(with: RotateGesture3D())
        .map { gesture in
            let (translation, scale, rotation) = gesture.components()
            return AffineTransform3D(
                scale: scale,
                rotation: rotation,
                translation: translation
            )
        }
}

以下では SimultaneousGesture の extension として components を定義しています。
これは上記の bimanualPinchAndTransformGesturegesture.components() で実行されていたものです。

そもそも、SimultaneousGesture は二つのジェスチャーを同時に扱うことができるようにするためのものです。以下のコードでは、SimultaneousGesture をさらに SimultaneousGesture でラップして、三つのジェスチャーを同時に扱えるようにしています。

translation に関しては、self.first?.first?DragGesture の値であり、そこからドラッグによる移動の値である translation3D を取得しています。

magnification に関しては、 self.first?.second?MagnifyGesture の値であり、そこから拡大縮小の倍率である magnification を取得しています。

size に関しては、取得した magnification に割り当てることで拡大縮小の倍率を幅・高さ・奥行きに適用しています。

rotation に関しては、 self.second?RotateGesture3D の値であり、そこから回転の値である rotation を取得しています。

これらをまとめてタプルで返却するようにしています。

extension SimultaneousGesture<
    SimultaneousGesture<DragGesture, MagnifyGesture>,
    RotateGesture3D>.Value {
        func components() -> (Vector3D, Size3D, Rotation3D) {
            let translation = self.first?.first?.translation3D ?? .zero
            let magnification = self.first?.second?.magnification ?? 1
            let size = Size3D(width: magnification, height: magnification, depth: magnification)
            let rotation = self.second?.rotation ?? .identity
            return (translation, size, rotation)
        }
    }

これで、ドラッグによる移動、拡大縮小の倍率、回転の値を受け取って使用できるようになりました。
上記のコードで実行しても見た目上は特に変化はないかと思います。

2. ジェスチャーの反映

次に前の章で取得したドラッグなどのジェスチャーをもとにモデルの大きさや位置を変更できるようにしたいと思います。コードを以下のように変更してみます。

import SwiftUI
import RealityKit
import RealityKitContent

struct BimanualPinchAndTransformView: View {
    
    @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace
    
    @PhysicalMetric(from: .meters) private var zOffsetOfModel = -2.5
    @PhysicalMetric(from: .meters) private var yOffsetOfModel = -1
    @PhysicalMetric(from: .meters) private var xOffsetOfCard = 0.5
    @PhysicalMetric(from: .meters) private var yOffsetOfCard = -1.6
    @PhysicalMetric(from: .meters) private var zOffsetOfCard = -2.1
    
    var bimanualPinchAndTransformGesture: some Gesture<AffineTransform3D> {
        DragGesture()
            .simultaneously(with: MagnifyGesture())
            .simultaneously(with: RotateGesture3D())
            .map { gesture in
                let (translation, scale, rotation) = gesture.components()
                return AffineTransform3D(
                    scale: scale,
                    rotation: rotation,
                    translation: translation
                )
            }
    }
    
+   struct ManipulationState {
+        var transform: AffineTransform3D = .identity
+        var active: Bool = false
+    }
+    
+    @GestureState private var manipulationState = ManipulationState()
+    @State private var totalTransform: AffineTransform3D = .identity

    var body: some View {
+       let combinedTransform = totalTransform.concatenating(manipulationState.transform)
        
        Model3D(named: "CarScene", bundle: realityKitContentBundle) { model in
            model
                .resizable()
                .scaledToFit()
+               .scaleEffect(combinedTransform.scale.width)
                .rotation3DEffect(.degrees(90), axis: .y)
+               .rotation3DEffect(combinedTransform.rotation ?? .identity)
                .offset(y: yOffsetOfModel)
                .offset(z: zOffsetOfModel)
+               .offset(
+                   x: combinedTransform.translation.x,
+                   y: combinedTransform.translation.y
+               )
+               .offset(z: combinedTransform.translation.z)
                .overlay {
                    VStack(spacing: 16) {
                        Text("Toyota FT-1 Vision GT")
                            .font(.largeTitle)
                        Text("説明文 ...")  
                            .frame(width: 500)
                        Button(action: {
                            Task {
                                await dismissImmersiveSpace()
                            }
                        }, label: {
                            Text("閉じる")
                                .padding()
                        })
                    }
                    .padding(50)
                    .glassBackgroundEffect()
                    .offset(x: xOffsetOfCard, y: yOffsetOfCard)
                    .offset(z: zOffsetOfCard)
                }
        } placeholder: {
            ProgressView()
        }
+      .animation(.easeInOut, value: combinedTransform)
+      .gesture(bimanualPinchAndTransformGesture
+      .updating($manipulationState) { value, state, _ in
+          state.active = true
+          state.transform = value
+      }
+      .onEnded { value in
+          totalTransform = totalTransform.concatenating(value)
+           }
+       )
    }
}

extension SimultaneousGesture<
    SimultaneousGesture<DragGesture, MagnifyGesture>,
    RotateGesture3D>.Value {
        func components() -> (Vector3D, Size3D, Rotation3D) {
            let translation = self.first?.first?.translation3D ?? .zero
            let magnification = self.first?.second?.magnification ?? 1
            let size = Size3D(width: magnification, height: magnification, depth: magnification)
            let rotation = self.second?.rotation ?? .identity
            return (translation, size, rotation)
        }
    }

追加した部分に関して詳しくみていきます。

以下では ManipulationState として transformactive を保持しています。
transform では AffineTransform3D 型でモデルの位置の状態を保持しています。

manipulationState では上記の状態を GestureState として保持しています。
GestureState で保持することで、manipulationState.transform で現在のモデルの位置にアクセスすることができます。

struct ManipulationState {
    var transform: AffineTransform3D = .identity
    var active: Bool = false
}

@GestureState private var manipulationState = ManipulationState()
@State private var totalTransform: AffineTransform3D = .identity

以下では二つの Transform を足し合わせることで、 combinedTransform としてモデルの位置をリアルタイムに反映させるための変数を定義しています。AffineTransform3D 型のデータは concatenating メソッドで二つを足し合わせることができます。

  • totalTransform:過去のジェスチャーで蓄積された移動を保存
  • manipulationState.transform:現在のジェスチャーによる移動を保存
let combinedTransform = totalTransform.concatenating(manipulationState.transform)

以下では、ジェスチャーによるリアルタイムのモデルの移動を保持している combinedTransform をもとにモデルの位置を変更しています。
scaleEffect ではモデルの大きさの特に幅をもとに大きさを変更しています。
rotation3DEffect ではリアルタイムの rotation を適用し、まだモデルが回転していない場合は .identity で回転のない状態を適用しています。
offset では x, y, z 軸のそれぞれで combinedTransform.translation をもとに位置を変更しています。これでリアルタイムにモデルの移動が可能になります。

    model
        .resizable()
        .scaledToFit()
+       .scaleEffect(combinedTransform.scale.width)
        .rotation3DEffect(.degrees(90), axis: .y)
+       .rotation3DEffect(combinedTransform.rotation ?? .identity)
        .offset(y: yOffsetOfModel)
        .offset(z: zOffsetOfModel)
+       .offset(
+           x: combinedTransform.translation.x,
+           y: combinedTransform.translation.y
+       )
+       .offset(z: combinedTransform.translation.z)

以下ではモデルに対してアニメーションを追加しています。
.animation でアニメーションが追加できます。 .easeInOut ではアニメーションの動き始めは移動速度が遅く、中盤にかけて速くなり、終盤にかけて遅くなるといった動きになります。
value には combinedTransform を指定しており、リアルタイムでモデルが動いている場合にアニメーションが適用されるようになっています。

+      .animation(.easeInOut, value: combinedTransform)

以下ではジェスチャーの設定を行なっています。
.gesture に対しては先ほど作成した bimanualPinchAndTransformGesture を割り当てています。これでモデルに対するジェスチャーからドラッグ、拡大縮小、回転の値を取得して AffineTransform3D に変換して返すようになっています。

.updating では manipulationState を渡して、ジェスチャーに更新があるたびに activetransform の値を変更するようにしています。

onEnded ではユーザーがジェスチャーを終了したときにそのジェスチャーの値を蓄積しておく処理を追加しています。これで一回ジェスチャーが終わってもモデルが元の位置や回転、サイズに戻ることなく、保存されるようになっています。

+      .gesture(bimanualPinchAndTransformGesture
+      .updating($manipulationState) { value, state, _ in
+          state.active = true
+          state.transform = value
+      }
+      .onEnded { value in
+          totalTransform = totalTransform.concatenating(value)
+           }
+       )

上記のコードで実行すると、以下の動画のように自由にモデルの位置や回転、サイズを変更できることがわかります。

https://youtu.be/y9HdHak_GtU

全体のコード
import SwiftUI
import RealityKit
import RealityKitContent

struct BimanualPinchAndTransformView: View {
   
   @Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace
   
   @PhysicalMetric(from: .meters) private var zOffsetOfModel = -2.5
   @PhysicalMetric(from: .meters) private var yOffsetOfModel = -1
   @PhysicalMetric(from: .meters) private var xOffsetOfCard = 0.5
   @PhysicalMetric(from: .meters) private var yOffsetOfCard = -1.6
   @PhysicalMetric(from: .meters) private var zOffsetOfCard = -2.1
   
   var bimanualPinchAndTransformGesture: some Gesture<AffineTransform3D> {
       DragGesture()
           .simultaneously(with: MagnifyGesture())
           .simultaneously(with: RotateGesture3D())
           .map { gesture in
               let (translation, scale, rotation) = gesture.components()
               return AffineTransform3D(
                   scale: scale,
                   rotation: rotation,
                   translation: translation
               )
           }
   }
   
   struct ManipulationState {
        var transform: AffineTransform3D = .identity
        var active: Bool = false
    }
    
    @GestureState private var manipulationState = ManipulationState()
    @State private var totalTransform: AffineTransform3D = .identity

   var body: some View {
       let combinedTransform = totalTransform.concatenating(manipulationState.transform)
       
       Model3D(named: "CarScene", bundle: realityKitContentBundle) { model in
           model
               .resizable()
               .scaledToFit()
               .scaleEffect(combinedTransform.scale.width)
               .rotation3DEffect(.degrees(90), axis: .y)
               .rotation3DEffect(combinedTransform.rotation ?? .identity)
               .offset(y: yOffsetOfModel)
               .offset(z: zOffsetOfModel)
               .offset(
                   x: combinedTransform.translation.x,
                   y: combinedTransform.translation.y
               )
               .offset(z: combinedTransform.translation.z)
               .overlay {
                   VStack(spacing: 16) {
                       Text("Toyota FT-1 Vision GT")
                           .font(.largeTitle)
                       Text("The Toyota FT-1 Vision Gran Turismo is a Vision Gran Turismo concept car made by Toyota. It appears in Gran Turismo 6 (as part of Update 1.12), Gran Turismo Sport, and Gran Turismo 7. It is a racing version of the Toyota FT-1, with features and designs required for pure racing competition.\n\nModel : https://skfb.ly/oQqMS\nReference : https://gran-turismo.fandom.com/wiki/Toyota_FT-1_Vision_Gran_Turismo")
                           .frame(width: 500)
                       HStack {
                           Button(action: {
                               Task {
                                   await dismissImmersiveSpace()
                               }
                           }, label: {
                               Text("閉じる")
                                   .padding()
                           })
                           Button(action: {
                               totalTransform = .identity
                           }, label: {
                               Text("元の位置へ")
                                   .padding()
                           })
                       }
                   }
                   .padding(50)
                   .glassBackgroundEffect()
                   .offset(x: xOffsetOfCard, y: yOffsetOfCard)
                   .offset(z: zOffsetOfCard)
               }
       } placeholder: {
           ProgressView()
       }
      .animation(.easeInOut, value: combinedTransform)
      .gesture(bimanualPinchAndTransformGesture
      .updating($manipulationState) { value, state, _ in
          state.active = true
          state.transform = value
      }
      .onEnded { value in
          totalTransform = totalTransform.concatenating(value)
           }
       )
   }
}

extension SimultaneousGesture<
   SimultaneousGesture<DragGesture, MagnifyGesture>,
   RotateGesture3D>.Value {
       func components() -> (Vector3D, Size3D, Rotation3D) {
           let translation = self.first?.first?.translation3D ?? .zero
           let magnification = self.first?.second?.magnification ?? 1
           let size = Size3D(width: magnification, height: magnification, depth: magnification)
           let rotation = self.second?.rotation ?? .identity
           return (translation, size, rotation)
       }
   }

以上です。

まとめ

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

Apple Vision Pro で他のアプリを触っていて、今回実装したように、ユーザーが自由にモデルを動かせる実装が多くあったため実装してみました。

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

参考

https://github.com/sarangborude/Dinopedia

Discussion