【Swift】visionOS でモデルに対するジェスチャーを感知する
初めに
今回は公式ドキュメントに用意されている Responding to gestures on an entity をもとに、Apple Vision Pro でモデルに対するユーザーのジェスチャーを感知するための実装についてみていきたいと思います。
記事の対象者
- Swift 学習者
- Apple Vision Pro で Entity の実装をしたい方
目的
今回の目的は、先述の通り、モデルに対するジェスチャーを感知してフィードバックを実装する方法をまとめることです。ホバーやタップを検知してそれに応じたアクションを実装することが目的です。
実装
実装は以下の手順で進めていきたいと思います。
まずは、公式ドキュメントのコードを読み解き、その後でカスタマイズした実装を行いたいと思います。
- 公式ドキュメントの実装
- 実装のカスタマイズ
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」というテキストのアタッチメントの表示 / 非表示が切り替わるようになっています。
それぞれコードを詳しくみていきます。
以下では、 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
を付与しています。
ActiveComponent
は cube
のアタッチメントの表示 / 非表示を参照するために設定しています。
cube.name = "Groovy Cube"
cube.components.set(ActiveComponent())
以下では RealityView
で、 content
に cube
を追加しています。
RealityView { content, attachments in
content.add(cube)
}
以下では、 RealityView
の update
を実装しています。
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)
}
}
以下では、 gesture
で RealityView
に対するジェスチャーを実装しています。
SpatialEventGesture().targetedToAnyEntity()
ですべてのエンティティに対する入力を受け入れるようにしています。
onEnded
ではジェスチャーが終了した際に ActiveComponent
の値を切り替えるようにしています。
.gesture(SpatialEventGesture()
.targetedToAnyEntity()
.onEnded { value in
value.entity.components[ActiveComponent.self]?.active.toggle()
})
これで実行すると以下の動画のように、ジェスチャーを受け取ってアタッチメントの表示を切り替えることができるかと思います。
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()
}
}
これで実行すると以下の動画のように、それぞれのモデルのアタッチメントの表示 / 非表示を切り替えることができ、どのモデルを見ているかをホバー時のアクションで分かりやすいようにしています。
基本的には前の章の実装と同じですが、異なる点に注目してみていきたいと思います。
以下では、モデルのアタッチメントが表示されているかどうかを管理する 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)"
)
}
)
これで実行すると以下の動画のように、それぞれのモデルのアタッチメントの表示 / 非表示を切り替えられることがわかります。
以上です。
まとめ
最後まで読んでいただいてありがとうございました。
公式ドキュメントの Component のページを見ると、今回触れたものを含む非常に多くのコンポーネントがまとめられていることがわかります。
それぞれ使用する場面で使用用途や実装方法を適宜まとめていければと思います。
誤っている点やもっと良い書き方があればご指摘いただければ幸いです。
参考
Discussion