【Swift】visionOS で3Dモデルをユーザーが動かせるようにする
初めに
今回は Apple Vision Pro でユーザーが3Dモデルを自由に動かせるような機能を実装していきたいと思います。具体的には、ユーザーの手の動きによってモデルの大きさや向きを変更できるようにしていきます。
記事の対象者
- Swift, SwiftUI 学習者
- visionOS の実装をしてみたい方
目的
今回は上記の通り、ユーザーが自由にモデルを動かせるような実装を行うことが目的です。
最終的には以下の動画のようにユーザーのハンドジェスチャーで自由にモデルを動かせるようにしたいと思います。
実装
実装は以下の手順で進めていきます。
- モデルを表示する
- モデルを動かせるように変更
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. 拡大、回転などのジェスチャーを受け取る
まずはユーザーがモデルを拡大したり回転させたりしたことを検知できるようにします。
以下のようにコードを追加します。
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()
ではユーザーのドラッグジェスチャーを取得しています。
また、simultaneously
に MagnifyGesture
と RotateGesture3D
を入れることでドラッグジェスチャーと同時に拡大縮小のジェスチャー(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
を定義しています。
これは上記の bimanualPinchAndTransformGesture
の gesture.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
として transform
と active
を保持しています。
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
を渡して、ジェスチャーに更新があるたびに active
と transform
の値を変更するようにしています。
onEnded
ではユーザーがジェスチャーを終了したときにそのジェスチャーの値を蓄積しておく処理を追加しています。これで一回ジェスチャーが終わってもモデルが元の位置や回転、サイズに戻ることなく、保存されるようになっています。
+ .gesture(bimanualPinchAndTransformGesture
+ .updating($manipulationState) { value, state, _ in
+ state.active = true
+ state.transform = value
+ }
+ .onEnded { value in
+ totalTransform = totalTransform.concatenating(value)
+ }
+ )
上記のコードで実行すると、以下の動画のように自由にモデルの位置や回転、サイズを変更できることがわかります。
全体のコード
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 で他のアプリを触っていて、今回実装したように、ユーザーが自由にモデルを動かせる実装が多くあったため実装してみました。
誤っている点やもっと良い書き方があればご指摘いただければ幸いです。
参考
Discussion