🤏

【Swift】visionOS でモデルに対するジェスチャーを感知する

2024/12/24に公開

初めに

今回は公式ドキュメントに用意されている Responding to gestures on an entity をもとに、Apple Vision Pro でモデルに対するユーザーのジェスチャーを感知するための実装についてみていきたいと思います。

記事の対象者

  • Swift 学習者
  • Apple Vision Pro で Entity の実装をしたい方

目的

今回の目的は、先述の通り、モデルに対するジェスチャーを感知してフィードバックを実装する方法をまとめることです。ホバーやタップを検知してそれに応じたアクションを実装することが目的です。

実装

実装は以下の手順で進めていきたいと思います。
まずは、公式ドキュメントのコードを読み解き、その後でカスタマイズした実装を行いたいと思います。

  1. 公式ドキュメントの実装
  2. 実装のカスタマイズ

1. 公式ドキュメントの実装

まずは公式ドキュメントのコードをもとにして、どのような実装が行われているか詳しくみていきます。
コードは以下のようになっています。

import SwiftUI
import RealityKit
import Observation

@Observable
public class ActiveComponent: Component {
    public var active: Bool = false
}

struct ContentView: View {
    var cube: ModelEntity

    init() {
        cube = ModelEntity(
            mesh: .generateBox(size: 0.1),
            materials: [SimpleMaterial(color: .orange, isMetallic: false)]
        )
        cube.components.set(InputTargetComponent())
        cube.components.set(CollisionComponent(shapes: [ShapeResource.generateBox(size: SIMD3<Float>(0.1, 0.1, 0.1))]))
        cube.name = "Groovy Cube"
        cube.components.set(ActiveComponent())
    }
    
    var body: some View {
        RealityView { content, attachments in
            content.add(cube)
        } update: { content, attachments in
            guard let component = cube.components[ActiveComponent.self] else { return }
            guard let attachmentEntity = attachments.entity(for: cube.id) else { return }
            if component.active {
                attachmentEntity.components.set(BillboardComponent())

                cube.addChild(attachmentEntity)
                attachmentEntity.setPosition(
                    SIMD3<Float>(0.0, 0.1, 0.0),
                    relativeTo: cube
                )
            } else {
                cube.removeChild(attachmentEntity)
            }
        } attachments: {
            Attachment(id: cube.id) {
                Text("\(cube.name)")
                    .padding()
                    .glassBackgroundEffect(in: RoundedRectangle(cornerRadius: 5.0))
                    .tag(cube.id)
            }
        }
        .gesture(SpatialEventGesture()
            .targetedToAnyEntity()
            .onEnded { value in
                value.entity.components[ActiveComponent.self]?.active.toggle()
            })
        .padding()
    }
}

上記のコードでアプリを実行すると以下の動画のようになります。
モデルをタップすると「Groovy Cube」というテキストのアタッチメントの表示 / 非表示が切り替わるようになっています。

https://youtu.be/khKsbw9ExgA

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

以下では、 ActiveComponent として、モデルの活性化 / 非活性化を保持するための Component を定義しています。この実装では、活性化している場合はアタッチメントを表示する実装になっているため、その判断に使用されます。

@Observable とすることで、 active の値の変化をUI側で検知することができるようになります。先述の通り、アタッチメントの表示 / 非表示を切り替えるためには、UI側でこの値を参照して動的に変更する必要があるため、 @Observable が付与されています。

@Observable
public class ActiveComponent: Component {
    public var active: Bool = false
}

以下では、 ContentView の初期化処理を実装しています。
ModelEntity 型の cube を先に定義しておき、初期化処理の中で設定を行なっています。

cube には大きさが 0.1 で色がオレンジ色の立方体を指定しています。
大きさや形の設定は mesh で指定でき、立方体以外の形の指定も可能です。
モデルのマテリアルの設定は materials で指定でき、複数のマテリアルを組み合わせて使用することも可能です。

struct ContentView: View {
    var cube: ModelEntity

    init() {
        cube = ModelEntity(
            mesh: .generateBox(size: 0.1),
            materials: [SimpleMaterial(color: .orange, isMetallic: false)]
        )

以下では、InputTargetComponent を渡すことで、モデルがユーザーからの入力を受け付けるようにしています。この InputTargetComponent と後述の CollisionComponent を組み合わせることで、ユーザーのタップなどの入力を受け付けることができるようになります。

InputTargetComponent の概要

InputTargetComponent の公式ドキュメントには以下のような記述があります。

This component should be added to an entity to inform the system that it should be treated as a target for input handling. It can be customized to require only specific forms of input like direct or indirect interactions. By default the component is configured to handle all forms of input on the system.

The hit testing shape that defines the entity’s interactive entity is defined by the CollisionComponent. To configure an entity for input but avoid any sort of physics-related processing, add an InputTargetComponent and CollisionComponent, but disable the CollisionComponent for collision detection,

InputTargetComponent behaves hierarchically, so if it is added to an entity that has descendants with CollisionComponents, those shapes will be used for input handling. The isEnabled flag can be used to override this behavior by adding the InputTargetComponent to a descendant and setting isEnabled to false.

(日本語訳)
このコンポーネントは、システムに対して、エンティティを入力処理の対象として扱うべきであることを知らせるために追加されます。特定の形式の入力(直接的または間接的な相互作用など)のみを必要とするようにカスタマイズできます。デフォルトでは、このコンポーネントはシステム上のすべての形式の入力を処理するように構成されています。

エンティティのインタラクティブな領域を定義するヒットテスト形状は、CollisionComponentによって決定されます。エンティティを入力用に構成しつつ、物理演算に関連する処理を避けたい場合は、InputTargetComponentとCollisionComponentを追加し、衝突検出のためのCollisionComponentを無効にしてください。

InputTargetComponentは階層的に動作するため、CollisionComponentsを持つ子孫エンティティがあるエンティティにこのコンポーネントを追加すると、それらの形状が入力処理に使用されます。この動作を上書きするには、子孫にInputTargetComponentを追加し、そのisEnabledフラグをfalseに設定してください



まとめると以下のようなことが言えそうです。

  • Entity を入力処理の対象として扱うために必要
  • 入力形式は direct, indirect, all があり、デフォルトではすべての入力を受け付ける
  • タップなどの入力を受け付ける領域は CollisionComponent の形状に左右される
cube.components.set(InputTargetComponent())

以下では、CollisionComponent を渡すことで、エンティティに衝突判定を持たせるようにしています。CollisionComponent を持つ別のエンティティとの衝突判定を持つようになります。

この Component がない場合は、ユーザーがエンティティをタップした際やホバー時の判定を受け付けることができなくなるため実装しています。

shapes で衝突判定の形状を指定することができ、 cube と同じ形にしています。

cube.components.set(
    CollisionComponent(shapes: [
        ShapeResource.generateBox(
            size: SIMD3<Float>(0.1, 0.1, 0.1)
        )
    ])
)

以下では、エンティティに名前と ActiveComponent を付与しています。
ActiveComponentcube のアタッチメントの表示 / 非表示を参照するために設定しています。

cube.name = "Groovy Cube"
cube.components.set(ActiveComponent())

以下では RealityView で、 contentcube を追加しています。

RealityView { content, attachments in
    content.add(cube)
} 

以下では、 RealityViewupdate を実装しています。
component では cube に付与されている ActiveComponent を読み取っています。
attachmentEntity では cube に付与されているアタッチメントを読み取っています。

component.isActive が true の場合はアタッチメントに BillboardComponent を設定して、 cube に追加しています。
BillboardComponent を付与することで、アタッチメントがユーザーの方を向くようになります。

} update: { content, attachments in
    guard let component = cube.components[ActiveComponent.self] else { return }
    guard let attachmentEntity = attachments.entity(for: cube.id) else { return }
    
    if component.isActive {
        attachmentEntity.components.set(BillboardComponent())
        cube.addChild(attachmentEntity)
        attachmentEntity.setPosition(SIMD3<Float>(0, 0.1, 0), relativeTo: cube)
    } else {
        cube.removeChild(attachmentEntity)
    }
} 

以下では、 attachments の実装を行なっています。
実装自体はテキストを表示させるのみになっています。

} attachments: {
    Attachment(id: cube.id) {
        Text("\(cube.name)")
            .padding()
            .glassBackgroundEffect(in: RoundedRectangle(cornerRadius: 5.0))
            .tag(cube.id)
    }
}

以下では、 gestureRealityView に対するジェスチャーを実装しています。
SpatialEventGesture().targetedToAnyEntity() ですべてのエンティティに対する入力を受け入れるようにしています。

onEnded ではジェスチャーが終了した際に ActiveComponent の値を切り替えるようにしています。

.gesture(SpatialEventGesture()
    .targetedToAnyEntity()
    .onEnded { value in
        value.entity.components[ActiveComponent.self]?.active.toggle()
    })

これで実行すると以下の動画のように、ジェスチャーを受け取ってアタッチメントの表示を切り替えることができるかと思います。

https://youtu.be/khKsbw9ExgA

2. 実装のカスタマイズ

次に、先ほどまでの実装をもとにして別のサンプルを作ってみたいと思います。
先程のサンプルは管理するモデルが一つだけでしたが、モデルが複数の場合についてみていきます。

以下のようなコードで実装してみます。

import SwiftUI
import RealityKit
import RealityKitContent
import Observation

@Observable
public class ActiveComponent: Component {
    var isActive: Bool = false
    var attachmentText: String = ""
}

class Planet {
    let name: String
    let modelName: String
    
    init(name: String, modelName: String) {
        self.name = name
        self.modelName = modelName
    }
}

struct SolarSystemGestureResponseContentView: View {
    var planets: [Planet] = [
        Planet(name: "Mercury", modelName: "Mercury"),
        Planet(name: "Venus", modelName: "Venus"),
        Planet(name: "Earth", modelName: "Earth"),
        Planet(name: "Mars", modelName: "Mars"),
        Planet(name: "Jupiter", modelName: "Jupiter"),
        Planet(name: "Saturn", modelName: "Saturn"),
        Planet(name: "Uranus", modelName: "Uranus"),
        Planet(name: "Neptune", modelName: "Neptune"),
    ]
    @State private var planetEntities: [Entity] = []
    
    var body: some View {
        RealityView { content, attachments in
            do {
                var xPosition: Float = -0.35
                var entitiesToAdd: [Entity] = []
                for planet in planets {
                    let entity = try await Entity(named: planet.modelName, in: realityKitContentBundle)
                    entity.scale = SIMD3<Float>(x: 0.25, y: 0.25, z: 0.25)
                    entity.position = SIMD3<Float>(x: xPosition, y: 0, z: 0)
                    entity.components.set(InputTargetComponent())
                    entity.components.set(CollisionComponent(shapes: [ShapeResource.generateSphere(radius: 0.1)]))
                    entity.name = planet.name
                    entity.components.set(HoverEffectComponent())
                    entity.components.set(ActiveComponent())
                    content.add(entity)
                    
                    entitiesToAdd.append(entity)
                    xPosition += 0.1
                }
                DispatchQueue.main.async {
                    self.planetEntities = entitiesToAdd
                }
            } catch {
                debugPrint("Failed to load entity: \(error)")
            }
        } update: { content, attachments in
            for planetEntity in planetEntities {
                guard let activeComponent = planetEntity.components[ActiveComponent.self] else {
                    debugPrint("ActiveComponent is not found: \(planetEntity.name)")
                    continue
                }
                guard let attachmentEntity = attachments.entity(for: planetEntity.id) else {
                    debugPrint("Attachment entity is not found: \(planetEntity.name)")
                    continue
                }
                
                if activeComponent.isActive {
                    attachmentEntity.components.set(BillboardComponent())
                    attachmentEntity.components.set(Transform(scale: SIMD3<Float>(1, 1, 1)))
                    if !planetEntity.children.contains(attachmentEntity) {
                        planetEntity.addChild(attachmentEntity)
                    }
                    attachmentEntity.setPosition(SIMD3<Float>(0, 0.2, 0), relativeTo: planetEntity)
                    debugPrint("Attachment to show: \(planetEntity.name)")
                } else {
                    attachmentEntity.components.set(Transform(scale: SIMD3<Float>(0, 0, 0)))
                    planetEntity.removeChild(attachmentEntity)
                    debugPrint("Attachment to hide: \(planetEntity.name)")
                }
            }
        } attachments: {
            ForEach(planetEntities, id: \.id) { planetEntity in
                Attachment(id: planetEntity.id) {
                    if let activeComponent = planetEntity.components[ActiveComponent.self], activeComponent.isActive {
                        Text(activeComponent.attachmentText)
                            .font(.system(size: 40))
                            .transition(.scale)
                            .padding(.vertical, 20)
                            .padding(.horizontal, 40)
                            .glassBackgroundEffect()
                            .tag(planetEntity.id)
                    }
                }
            }
        }
        .gesture(SpatialTapGesture()
            .targetedToAnyEntity()
            .onEnded { value in
                guard let activeComponent = value.entity.components[ActiveComponent.self] else {
                    debugPrint("ActiveComponent is not found: \(value.entity.name)")
                    return
                }
                activeComponent.isActive.toggle()
                activeComponent.attachmentText = activeComponent.isActive ? value.entity.name : ""
                value.entity.components[ActiveComponent.self] = activeComponent
                debugPrint("Tapped entity: \(value.entity.name), isActive: \(activeComponent.isActive)")
            }
        )
        .padding()
    }
}

これで実行すると以下の動画のように、それぞれのモデルのアタッチメントの表示 / 非表示を切り替えることができ、どのモデルを見ているかをホバー時のアクションで分かりやすいようにしています。

https://youtu.be/hrR6HUJrU8M

基本的には前の章の実装と同じですが、異なる点に注目してみていきたいと思います。

以下では、モデルのアタッチメントが表示されているかどうかを管理する ActiveComponent を定義しています。isActive に加えて、 attachmentText としてモデルに表示するアタッチメントのテキストも保持するようにしています。

@Observable
public class ActiveComponent: Component {
    var isActive: Bool = false
    var attachmentText: String = ""
}

以下では、 Planet として、惑星の名前と、モデルの名前を保持するようにしています。
前の章では、 generateBox で立方体のモデルを生成しましたが、この章ではモデルをアセットから取り込んで表示させるためこのようなクラスを定義しています。

class Planet {
    let name: String
    let modelName: String
    
    init(name: String, modelName: String) {
        self.name = name
        self.modelName = modelName
    }
}

以下では、それぞれの Planet のモデルの数だけエンティティの設定をして、シーンに追加しています。
前の章の実装と同様に InputTargetComponent, CollisionComponent, ActiveComponent を渡しており、これでユーザーのタップを検知することができるようになります。

また、 HoverEffectComponent を渡すことで、モデルがホバーされた時、つまりユーザーの目線がモデルに合った時にモデルの色が変わるようにしています。

for planet in planets {
    let entity = try await Entity(named: planet.modelName, in: realityKitContentBundle)
    entity.scale = SIMD3<Float>(x: 0.25, y: 0.25, z: 0.25)
    entity.position = SIMD3<Float>(x: xPosition, y: 0, z: 0)
    entity.components.set(InputTargetComponent())
    entity.components.set(CollisionComponent(shapes: [ShapeResource.generateSphere(radius: 0.1)]))
    entity.name = planet.name
    entity.components.set(HoverEffectComponent())
    entity.components.set(ActiveComponent())
    content.add(entity)
    
    entitiesToAdd.append(entity)
    xPosition += 0.1
}
DispatchQueue.main.async {
    self.planetEntities = entitiesToAdd
}

以下では、 update の処理を記述しています。
planetEntities のそれぞれのクラスにおいて、 for文でアタッチメントの設定を行なっています。基本的には前の章の実装と同じかと思います。

} update: { content, attachments in
    for planetEntity in planetEntities {
        guard let activeComponent = planetEntity.components[ActiveComponent.self] else {
            debugPrint("ActiveComponent is not found: \(planetEntity.name)")
            continue
        }
        guard let attachmentEntity = attachments.entity(for: planetEntity.id) else {
            debugPrint("Attachment entity is not found: \(planetEntity.name)")
            continue
        }

        if activeComponent.isActive {
            attachmentEntity.components.set(BillboardComponent())
            attachmentEntity.components.set(Transform(scale: SIMD3<Float>(1, 1, 1)))
            if !planetEntity.children.contains(attachmentEntity) {
                planetEntity.addChild(attachmentEntity)
            }
            attachmentEntity.setPosition(SIMD3<Float>(0, 0.2, 0), relativeTo: planetEntity)
            debugPrint("Attachment to show: \(planetEntity.name)")
        } else {
            attachmentEntity.components.set(Transform(scale: SIMD3<Float>(0, 0, 0)))
            planetEntity.removeChild(attachmentEntity)
            debugPrint("Attachment to hide: \(planetEntity.name)")
        }
    }
} 

以下では atachments の実装を行なっています。
こちらも前の章とほぼ同じ実装ですが、 ForEach で複数のモデルに対するアタッチメントの付与を行なっています。

} attachments: {
    ForEach(planetEntities, id: \.id) { planetEntity in
        Attachment(id: planetEntity.id) {
            if let activeComponent = planetEntity.components[ActiveComponent.self],
                activeComponent.isActive
            {
                Text(activeComponent.attachmentText)
                    .font(.system(size: 40))
                    .transition(.scale)
                    .padding(.vertical, 20)
                    .padding(.horizontal, 40)
                    .glassBackgroundEffect()
                    .tag(planetEntity.id)
            }
        }
    }
}

以下では、gesture でモデルに対するジェスチャーの設定を行なっています。
前の章では扱うモデルが一つだったため、アタッチメントのテキストはそのまま指定していましたが、以下では、 activeComponent.attachmentText を参照して、それぞれのモデルに対して異なるテキストのアタッチメントを付与するようにしています。

.gesture(
    SpatialTapGesture()
        .targetedToAnyEntity()
        .onEnded { value in
            guard let activeComponent = value.entity.components[ActiveComponent.self] else {
                debugPrint("ActiveComponent is not found: \(value.entity.name)")
                return
            }
            activeComponent.isActive.toggle()
            activeComponent.attachmentText =
                activeComponent.isActive ? value.entity.name : ""
            value.entity.components[ActiveComponent.self] = activeComponent
            debugPrint(
                "Tapped entity: \(value.entity.name), isActive: \(activeComponent.isActive)"
            )
        }
)

これで実行すると以下の動画のように、それぞれのモデルのアタッチメントの表示 / 非表示を切り替えられることがわかります。

https://youtu.be/hrR6HUJrU8M

以上です。

まとめ

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

公式ドキュメントの Component のページを見ると、今回触れたものを含む非常に多くのコンポーネントがまとめられていることがわかります。
それぞれ使用する場面で使用用途や実装方法を適宜まとめていければと思います。

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

参考

https://developer.apple.com/documentation/realitykit/responding-to-gestures-on-an-entity

https://developer.apple.com/documentation/realitykit/inputtargetcomponent

Discussion