【visionOS】複数のジェスチャー操作を統合する(Entity編)
はじめに
今回はモデルのジェスチャー操作を統合する方法について解説します。
モデルの移動、スケール、回転をジェスチャー操作で実現する際は、それぞれDragGesture、MagnifyGesture、 RotateGesture3Dを使用することが多いかと思います。
これらのジェスチャーを統合することで、「移動しながら回転させる」といったように、ジェスチャーで操作中に別のジェスチャーを実行する、という処理が可能になります。
この記事では、RealityKitのEntityクラスでモデルを表示する場合の処理を書いております。
Model3Dで表示する場合は以下の記事を参考にしてください。また、本記事でも使用しているコードが一部あるため、詳細を知りたい場合も参考にしていただければ幸いです。
【visionOS】複数のジェスチャー操作を統合する(Model3D編)
また、特定のジェスチャーだけ実行し、ジェスチャー後に元の場所に戻る、といった機能も織り交ぜております。
環境
Swift Version: 6.0
Xcode: 16.1
visionOS: 2.1
macOS: Sonoma 14.7
前準備
RealityViewの設定
RealityViewの準備をします。
import SwiftUI
import RealityKit
import RealityKitContent
struct GestureForEntityImmersiveView: View {
var body: some View {
RealityView { content in
if let scene = try? await Entity(named: "GestureEntities", in: realityKitContentBundle) {
content.add(scene)
}
}
}
}
今回は正面に3つのモデルを並べるだけのシーンを作りました。
ジェスチャー操作の対象にするために、各モデルにInputTargetとCollisionを付与します。
ジェスチャー操作用のカスタムモディファイアを作成
import SwiftUI
import RealityKit
public extension RealityView {
/// ジェスチャーを付与
func installManipulationGesture() -> some View {
self.gesture(manipulationGestureToAnyEntity)
}
/// 統合されたジェスチャー
private var manipulationGestureToAnyEntity: some Gesture {
manipulationGesture() /// 複数のジェスチャーの値からAffineTransform3Dを生成
.targetedToAnyEntity()
}
}
import SwiftUI
import RealityKit
import RealityKitContent
struct GestureForEntityImmersiveView: View {
var body: some View {
RealityView { content in
if let scene = try? await Entity(named: "GestureEntities", in: realityKitContentBundle) {
content.add(scene)
}
}
.installManipulationGesture() /// 追加
}
}
installManipulationGesture()でRealityViewに対してジェスチャーを付与します。manipulationGestureToAnyEntity: some Gestureで統合されたジェスチャーを呼び出しています。
manipulationGesture()はModel3Dで実装した記事で記載したものと同じコードを使用しました。(今回はViewの拡張機能として実装しています)
import SwiftUI
import RealityKit
public extension View {
func manipulationGesture() -> 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
)
}
}
}
import SwiftUI
/// DragGesture、MagnifyGesture、RotateGesture3Dを結合し、それぞれの値をタプルで返す
public 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)
}
}
Componentを作る
ジェスチャー開始時のモデルの状態(位置、スケール、回転)を保持しておく必要があります。
ジェスチャー開始時の値に、現在実行しているジェスチャーの値を加える、という処理になります。
今回は、ジェスチャー開始時の状態を保持するためのGestureStartComponentと、ジェスチャーの値からモデルを更新するGestureComponentを作成します。
GestureStartComponent (ジェスチャー開始時の状態を保持)
import SwiftUI
import RealityKit
/// ジェスチャー開始時の状態を保存するコンポーネント
public struct GestureStartComponent: Component {
public let transform: Transform
public init(transform: Transform) {
self.transform = transform
}
public var affineTransform3D: AffineTransform3D {
AffineTransform3D(transform)
}
}
ジェスチャー開始時のモデルのTransformを保存します。また、位置、スケール、回転を更新するためにAffineTransform3Dに変換して返すプロパティを追加してます。
GestureComponent (ジェスチャーの値からモデルを更新)
import SwiftUI
import RealityKit
public struct GestureComponent: Codable, Component {
/// 各操作ができるか
public var canDrag: Bool = true
public var canScale: Bool = true
public var canRotate: Bool = true
/// ジェスチャー終了時に元に戻すか
public var resetOnEnded: Bool = false
private init() {}
/// ジェスチャー実行中の処理
@MainActor
func onChanged(value: EntityTargetValue<AffineTransform3D>) {
let target = value.entity
/// ジェスチャー開始時にGestureStartComponentを付与
if !target.components.has(GestureStartComponent.self) {
target.gestureStartComponent = GestureStartComponent(transform: target.transform)
}
/// Transformの更新
setTransform(value: value)
}
/// ジェスチャー終了後の処理
@MainActor
func onEnded(value: EntityTargetValue<AffineTransform3D>) {
/// 元に戻すかそのままか
if resetOnEnded {
resetTransform(value: value, duration: 1.0)
} else {
setTransform(value: value)
}
/// GestureStartComponentを消す
value.entity.components.remove(GestureStartComponent.self)
}
/// 元に戻す
@MainActor
private func resetTransform(value: EntityTargetValue<AffineTransform3D>, duration: TimeInterval) {
/// ジェスチャー開始時の状態にアニメーション付きで戻す
guard let affineTransform3D = value.entity.gestureStartComponent?.affineTransform3D else {
return
}
value.entity.move(to: Transform(affineTransform3D), relativeTo: nil, duration: duration)
}
/// Transformの更新
@MainActor
private func setTransform(value: EntityTargetValue<AffineTransform3D>) {
let target = value.entity
/// ジェスチャーしたTranslationの値を、空間内のTranslationへ変換
let convertedTranslationValue = value.convertedTranslation(from: .local, to: .scene)
/// 更新
guard let newAffine = target.gestureStartComponent?.affineTransform3D.updated(with: convertedTranslationValue, canDrag: canDrag, canScale: canScale, canRotate: canRotate) else {
return
}
let transform = Transform(newAffine)
target.setTransformMatrix(transform.matrix, relativeTo: nil)
}
}
プロパティには各ジェスチャーができるかどうかのBool値を定義しています。特定のジェスチャーのみ有効/無効の設定ができるようにします。また、ジェスチャー終了後に元に戻すかの判定も行います。
また、RealityComposerProで自作のComponentを呼び出す場合は、App.swiftの初期化時に.registerComponent()で登録が必要です。
init() {
RealityKitContent.GestureComponent.registerComponent()
}
これらのプロパティはBuild後にRealityComposerProで確認するとGestureComponentが追加できるようになっているため、要件に合わせてBool値を設定しましょう。
func onChanged()では、ジェスチャーの値をモデルに反映させる処理を行います。GestureStartComponentがEntityに付与されていない場合はジェスチャー開始と判断し、現在のEntityのTransformからGestureStartComponentを生成してEntityに付与します。その後、setTransform()でジェスチャーで操作した値の分だけモデルの状態を更新する処理を行います(後述)。
func onEnded()では、ジェスチャーの終了時の処理をします。元に戻す場合はresetTransform()でGestureStartComponentで保存したTransformへ戻ることで、ジェスチャー前の状態に戻る処理が簡単に実現できます。元に戻らない場合はonChanged()と同様にsetTransform()を実行します。また、ジェスチャー終了後は次のジェスチャー時に新たにGestureStartComponentを設定するために、ジェスチャー開始時に付与したGestureStartComponentを削除します。
func setTransform()でジェスチャーの値に合わせてモデルの状態を更新します。更新作業をする前にジェスチャーした位置情報を空間内の位置に変換する必要があります。
/// ジェスチャーのTranslationの値を、空間内のTranslationへ変換
let convertedTranslationValue = value.convertedTranslation(from: .local, to: .scene)
import SwiftUI
import RealityKit
public extension EntityTargetValue where Value == AffineTransform3D {
/// Translationを変換する
func convertedTranslation(from: LocalCoordinateSpace, to: SceneRealityCoordinateSpace) -> AffineTransform3D {
let offset = convert(gestureValue.translation, from: from, to: to)
return AffineTransform3D(
scale: self.scale,
rotation: self.rotation ?? .identity,
translation: Vector3D(offset) /// 変換後の値に差し替え
)
}
}
スケールと回転はそのままで、位置のみを変換しています。
その後、ジェスチャー開始時のTransformからジェスチャーで得た値の分を更新します。
/// 更新
guard let newAffine = target.gestureStartComponent?.affineTransform3D.updated(with: convertedTranslationValue, canDrag: canDrag, canScale: canScale, canRotate: canRotate) else {
return
}
(模写していただいてる場合は、target.gestureStartComponent?でエラーになります。後述するコードを追記すれば解消できますのでこのまま進めましょう)
更新にはModel3Dで実装した記事で記載した関数updated()を使用しますが、今回はGestureComponentのプロパティである各ジェスチャーを使用できるかどうかのBool値(canDrag, canScale, canRotate)を引数として渡します。
import SwiftUI
public extension AffineTransform3D {
func updated(with value: AffineTransform3D, canDrag: Bool = true, canScale: Bool = true, canRotate: Bool = true) -> AffineTransform3D {
var newTransform: AffineTransform3D = .identity
/// Update Translation
if canDrag { /// ドラッグできるか
newTransform.translation = self.translation + value.translation
} else {
newTransform.translation = self.translation
}
/// Update Scale
if canScale { /// スケールを変更できるか
let newScale = self.scale.scaled(by: value.scale)
newTransform.scale(by: newScale)
}
/// Update Rotation
if canRotate { /// 回転できるか
if
let rotation = value.rotation,
let newRotation = self.rotation?.rotated(by: rotation)
{
newTransform.rotate(by: newRotation)
}
}
return newTransform
}
}
最後に、更新したAffineTransform3DをTransformに戻してEntityを更新します。
let transform = Transform(newAffine)
target.setTransformMatrix(transform.matrix, relativeTo: nil)
これでジェスチャーの一連の処理は完成です。これら処理をRealityViewに紐づけましょう。
RealityViewに紐づける
2つのComponentを取得/更新しやすいようにする
今回実装したComponentを取得/更新しやすいように、Entityにextensionを追加します。
import RealityKit
public extension Entity {
var gestureComponent: GestureComponent? {
get { components[GestureComponent.self] }
set { components[GestureComponent.self] = newValue }
}
var gestureStartComponent: GestureStartComponent? {
get { components[GestureStartComponent.self] }
set { components[GestureStartComponent.self] = newValue }
}
}
GestureComponentを実行する関数を作成
GestureComponentを呼び出すためのGesture構造体を生成するための関数func useGestureComponent()を定義します。
GestureComponentは統合されたジェスチャーの値をAffineTransform3Dに変換した場合のみ使用できるため、ジェスチャーした値がAffineTransform3Dであるという条件を加えたGestureのextensionで定義します。
import SwiftUI
import RealityKit
public extension Gesture where Value == EntityTargetValue<AffineTransform3D> {
func useGestureComponent() -> some Gesture {
onChanged { value in
guard let gestureComponent = value.entity.gestureComponent else {
return
}
/// 更新処理
gestureComponent.onChanged(value: value)
}
.onEnded { value in
guard let gestureComponent = value.entity.gestureComponent else {
return
}
/// 終了処理
gestureComponent.onEnded(value: value)
}
}
}
onChangedとonEndedのそれぞれに、ジェスチャーしたEntityにGestureComponentが付与されているかの確認を行います。あとは、GestureComponent側の各メソッドを実行するだけです。
RealityViewに紐付け
useGestureComponent()をRealityViewのextensionで定義したmanipulationGestureToAnyEntityの最後の行に追加します。
import SwiftUI
import RealityKit
public extension RealityView {
func installManipulationGesture() -> some View {
self.gesture(manipulationGestureToAnyEntity)
}
private var manipulationGestureToAnyEntity: some Gesture {
manipulationGesture()
.targetedToAnyEntity()
.useGestureComponent() /// 追加
}
}
これでRealityViewへの紐付けは完了です。実際にBuildして確かめてみてください。
終わりに
今回は、Entityで表示したモデルに対して複数のジェスチャーを統合して同時に操作する方法を書きました。
今回参考にしたAppleの公式サンプルアプリではジェスチャーしているEntityの情報をシングルトンで保存する方法をとっています、これらの実装は非concurrency-safeでであり、Swift6以降では使用できなくなるため修正が必須です。
これに関する質問にAppleが回答しています。
また、回答の中で代替案が提示されていましたが、具体的なコードが記載されてないため、こちらで自作してしようと思い執筆しました(Swift6でも問題なく動いています)。
お役に立てたら幸いです。
参考
Apple公式サンプルアプリ: https://developer.apple.com/documentation/realitykit/transforming-realitykit-entities-with-gestures
Apple公式サンプルアプリが非concurrency-safeである問題に関するDeveloper Forms: https://forums.developer.apple.com/forums/thread/761191