見出し画像

【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が回答しています。

This is not a bug. This is an error that is triggered when using the Swift 6 Language. Inside your target’s Build Settings you can change the Swift Language Version to Swift 5 to temporarily avoid these errors. If you go this route you should enable Complete Swift Concurrency Checking to create warnings for these issues that are errors in Swift 6.

(翻訳文)

これはバグではない。これはSwift 6 Languageを使用する際に発生するエラーです。ターゲットのビルド設定の中で、これらのエラーを一時的に回避するために、Swift言語バージョンをSwift 5に変更することができます。このルートを行く場合は、Swift 6でエラーであるこれらの問題の警告を作成するために、完全なSwiftの同時実行チェックを有効にする必要があります。

また、回答の中で代替案が提示されていましたが、具体的なコードが記載されてないため、こちらで自作してしようと思い執筆しました(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

#RealityKit #Entity

いいなと思ったら応援しよう!