【Swift】visionOS でユーザーの位置に合わせてモデルを表示する
初めに
今回は Apple Vision Pro でユーザーの位置に合わせてモデルの位置を調整する方法をまとめていきたいと思います。ユーザーの位置に応じた表現は Apple が発表している Encounter Dinosaurs でも使用されているかなと思います。
記事の対象者
- SwiftUI 学習者
- Apple Vision Pro の実装について知りたい方
目的
今回の目的は、先述の通り「ユーザーの位置に合わせてモデルの位置を調整する」方法を学ぶことです。
今回は Placing entities using head and device transform のプロジェクトのコードをもとにして、内容を見ていきたいと思います。
実装
実装は以下の手順で進めていきたいと思います。
- AppModel の実装
- System, Component の実装
- View の実装
- App の実装
1. AppModel の実装
まずは AppModel の実装を進めていきます。
コードは以下の通りです。
headTrackState
では現在選択されているトラッキングのモードを保持しておきます。
follow
はユーザーのデバイスの位置に追従するようなモードで、 headPosition
はユーザーのデバイスの位置に固定するようなモードにしています。
isImmersiveSpaceOpen
では ImmersiveSpace が開いているかどうかを保持するようにしています。
import SwiftUI
import RealityKit
import ARKit
@MainActor
@Observable
class HeadPositionAppModel {
var headTrackState: HeadTrackState = .headPosition
var isImmersiveSpaceOpen: Bool = false
enum HeadTrackState: String, CaseIterable {
case follow
case headPosition = "head-position"
}
}
2. System, Component の実装
次に System の実装を進めていきます。
System については、コードのコメントがわかりやすかったので、日本語訳して引用します。
システム はシーン内の複数のエンティティに影響を与える継続的な動作を表します。システムを使用して、オブジェクトやキャラクターなど、シーンの更新ごとにエンティティを更新する動作やロジックを実装します。
たとえば、物理シミュレーションシステムは、すべてのエンティティに対して重力、力、および衝突の影響を計算して適用します。
今回は「ユーザーの位置に追従してモデルの位置を更新する」という System を作成していきます。
コードは以下の通りです。
import RealityKit
import SwiftUI
import ARKit
public struct FollowSystem: System {
static let query = EntityQuery(where: .has(FollowComponent.self))
private let arkitSession = ARKitSession()
private let worldTrackingProvider = WorldTrackingProvider()
public init(scene: RealityKit.Scene) {
runSession()
}
func runSession() {
Task {
do {
try await arkitSession.run([worldTrackingProvider])
} catch {
print("Error: \(error). Head position mode will still work.")
}
}
}
public func update(context: SceneUpdateContext) {
guard worldTrackingProvider.state == .running else { return }
guard let deviceAnchor = worldTrackingProvider.queryDeviceAnchor(atTimestamp: CACurrentMediaTime()) else {
return
}
let deviceTransform = Transform(matrix: deviceAnchor.originFromAnchorTransform)
let entities = context.entities(matching: Self.query, updatingSystemWhen: .rendering)
for entitiy in entities {
entitiy.move(to: deviceTransform, relativeTo: entitiy.parent, duration: 1.2, timingFunction: .easeInOut)
}
}
}
それぞれ詳しくみていきます。
以下では System
を継承した FollowSystem
を定義しています。
query
では、後述の FollowComponent
が付与されている Entity のみを抽出して定義しています。
arkitSession
, worldTrackingProvider
では、それぞれのインスタンスを保持しています。今回はユーザーの位置を特定して、トラッキングするために ARKitSession
と WorldTrackingProvider
を使用します。
public struct FollowSystem: System {
static let query = EntityQuery(where: .has(FollowComponent.self))
private let arkitSession = ARKitSession()
private let worldTrackingProvider = WorldTrackingProvider()
以下では、初期化の処理として runSession
を実行しています。
runSession
メソッドでは、 worldTrackingProvider
を含む ARKitSession
を実行しています。
public init(scene: RealityKit.Scene) {
runSession()
}
func runSession() {
Task {
do {
try await arkitSession.run([worldTrackingProvider])
} catch {
print("Error: \(error). Head position mode will still work.")
}
}
}
以下では、 update
メソッドを定義しています。
ユーザーのデバイスの位置は queryDeviceAnchor
で取得できます。
取得したデバイスの位置を deviceTransform
として定義しています。
entities
では、この FollowSystem
の対象となる Entity を見つけてきています。
そして、対象となる entities
に対して、それぞれ move
メソッドでデバイスの位置を指定して移動するように設定しています。
これで、「特定のEntityがユーザーのデバイスの位置に追従して動く」という挙動が実現できます。
public func update(context: SceneUpdateContext) {
guard worldTrackingProvider.state == .running else { return }
guard let deviceAnchor = worldTrackingProvider.queryDeviceAnchor(atTimestamp: CACurrentMediaTime()) else {
return
}
let deviceTransform = Transform(matrix: deviceAnchor.originFromAnchorTransform)
let entities = context.entities(matching: Self.query, updatingSystemWhen: .rendering)
for entitiy in entities {
entitiy.move(to: deviceTransform, relativeTo: entitiy.parent, duration: 1.2, timingFunction: .easeInOut)
}
}
次に Component の実装を行います。
Component に関してもコードのコメントの文章がわかりやすかったので、日本語訳して引用します。
エンティティに特定の動作や外観を組み合わせるには、EntityインスタンスのEntity/componentsセットにコンポーネントを追加します。それぞれのコンポーネントは、Componentプロトコルに準拠した型によって表され、エンティティの単一の側面を定義します。たとえば、あるコンポーネントは空間内での位置を定義し、別のコンポーネントは視覚的な外観を提供します。エンティティには、特定の型のコンポーネントを1つだけ追加できます。
今回は先ほど実装した FollowSystem
の対象となる Entity を区別するために FollowComponent
を使用します。
コードは以下の通りです。 Component, Codable を継承したシンプルなものになっており、特に内部の実装は必要ありません。あくまで FollowSystem
の対象になっているものに付与して区別するという目的で使用します。
import RealityKit
import SwiftUI
import ARKit
public struct FollowComponent: Component, Codable {}
これで System と Component の実装は完了です。
3. View の実装
次に View の実装に移ります。
View はモードを切り替える TogglePanel
と、モデルを表示させる FollowImmersiveView
の二つを実装していきます。
まずは TogglePanel
の実装を行います。
コードは以下の通りです。
import SwiftUI
struct TogglePanel: View {
@Environment(HeadPositionAppModel.self) private var appModel
@Environment(\.openImmersiveSpace) var openImmersiveSpace
@Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace
var body: some View {
@Bindable var appModel = appModel
Group {
if !appModel.isImmersiveSpaceOpen {
Button("Launch Airplane") {
Task {
await openImmersiveSpace(id: "FollowImmersiveView")
appModel.isImmersiveSpaceOpen = true
}
}
} else {
VStack {
Spacer()
Text("Currently enabled: \(appModel.headTrackState.rawValue.capitalized) mode")
Picker("Current state", selection: $appModel.headTrackState) {
ForEach(HeadPositionAppModel.HeadTrackState.allCases, id: \.self) { state in
Text("\(state.rawValue.capitalized) mode")
}
}
.pickerStyle(SegmentedPickerStyle())
Spacer()
Button("Exit immersive space") {
Task {
await dismissImmersiveSpace()
appModel.isImmersiveSpaceOpen = false
}
}
}
.padding()
}
}
}
}
それぞれ詳しくてみていきます。
以下では、 TogglePanel
に必要な変数の定義をしています。
appModel
では HeadPositionAppModel
を定義しています。
openImmersiveSpace
, dismissImmersiveSpace
では ImmersiveSpace を開いたり閉じたりする処理を定義しています。
struct TogglePanel: View {
@Environment(HeadPositionAppModel.self) private var appModel
@Environment(\.openImmersiveSpace) var openImmersiveSpace
@Environment(\.dismissImmersiveSpace) var dismissImmersiveSpace
以下では、 ImmersiveSpace が開いていない場合の View を定義しています。
ImmersiveSpace が開いていない場合は Button
を表示して、FollowImmersiveView
のスペースを開くようにしています。
var body: some View {
@Bindable var appModel = appModel
Group {
if !appModel.isImmersiveSpaceOpen {
Button("Launch Airplane") {
Task {
await openImmersiveSpace(id: "FollowImmersiveView")
appModel.isImmersiveSpaceOpen = true
}
}
}
以下では、ImmersiveSpace が開いている場合のViewを定義しています。
モデルを表示させるモードの HeadTrackState
を切り替えるための Picker や、スペースを閉じるための Button を表示させています。
} else {
VStack {
Spacer()
Text("Currently enabled: \(appModel.headTrackState.rawValue.capitalized) mode")
Picker("Current state", selection: $appModel.headTrackState) {
ForEach(HeadPositionAppModel.HeadTrackState.allCases, id: \.self) { state in
Text("\(state.rawValue.capitalized) mode")
}
}
.pickerStyle(SegmentedPickerStyle())
Spacer()
Button("Exit immersive space") {
Task {
await dismissImmersiveSpace()
appModel.isImmersiveSpaceOpen = false
}
}
}
.padding()
}
次に FollowImmersiveView
の実装を行います。
コードは以下の通りです。
import SwiftUI
import RealityKit
import RealityKitContent
import ARKit
struct FollowImmersiveView: View {
@Environment(HeadPositionAppModel.self) private var appModel
let followRoot: Entity = Entity()
let headAnchorRoot: Entity = Entity()
let headPositionedEntitiesRoot: Entity = Entity()
let target: Entity = Entity()
var body: some View {
RealityView { content in
do {
let targetContent = try await Entity(named: "ToyBiplane", in: realityKitContentBundle)
targetContent.scale = SIMD3<Float>(x: 1, y: 1, z: 1)
target.addChild(targetContent)
followRoot.components.set(FollowComponent())
content.add(followRoot)
content.add(headAnchorRoot)
startHeadPositionMode(content: content)
} catch {
fatalError("No entity to load")
}
} update: { content in
toggleMode(content: content)
}
.onDisappear {
if let targetContent = target.children.first {
target.removeChild(targetContent)
}
}
}
}
extension FollowImmersiveView {
func startFollowMode() {
guard let headAnchor = headAnchorRoot.children.first(where: { $0.name == "headAnchor" }) else { return }
headAnchorRoot.removeChild(headAnchor)
followRoot.setPosition([0, 1, -1], relativeTo: nil)
let orientation = simd_quatf(angle: .pi * -0.15, axis: [0, 1, 0]) * simd_quatf(angle: .pi * 0.2, axis: [1, 0, 0])
target.transform.rotation = orientation
followRoot.addChild(target)
target.setPosition([0.4, 0.2, -1], relativeTo: followRoot)
}
func startHeadPositionMode(content: RealityViewContent) {
target.transform.rotation = simd_quatf()
let headAnchor = AnchorEntity(.head)
headAnchor.anchoring.trackingMode = .once
headAnchor.name = "headAnchor"
headAnchorRoot.addChild(headAnchor)
headPositionedEntitiesRoot.addChild(target)
target.setPosition([0, 0, -0.15], relativeTo: headPositionedEntitiesRoot)
headAnchor.addChild(headPositionedEntitiesRoot)
headPositionedEntitiesRoot.setPosition([0, 0, -0.6], relativeTo: headAnchor)
}
func toggleMode(content: RealityViewContent) {
switch appModel.headTrackState {
case .follow:
startFollowMode()
case .headPosition:
startHeadPositionMode(content: content)
}
}
}
それぞれ詳しくみていきます。
以下では、 FollowImmersiveView
に必要な変数の定義を行なっています。
appModel
では、HeadPositionAppModel
を受け取るようにしています。
followRoot
では、追従するモデルのルートとなる Entity
を定義しています。
headAnchorRoot
では、ユーザーのデバイスの位置を保持するルートとなる Entity
を定義しています。
headPositionedEntitiesRoot
では、ユーザーのデバイスの位置にモデルを固定する場合の、デバイスの位置を保持する Entity
を定義しています。
target
では、ユーザーの位置を追従するモデル自体の Entity
を保持するようにしています。
struct FollowImmersiveView: View {
@Environment(HeadPositionAppModel.self) private var appModel
let followRoot: Entity = Entity()
let headAnchorRoot: Entity = Entity()
let headPositionedEntitiesRoot: Entity = Entity()
let target: Entity = Entity()
以下では、FollowImmersiveView
の body
として RealityView
を指定しています。
ToyBiplane
というモデルを読み込んで、 targetContent
に代入しています。
モデルは必要に応じて大きさを調整して、 target.addChild
メソッドで、 target
の子要素としてシーンに追加されます。
followRoot.components.set
メソッドで FollowComponent
を渡すことで、 FollowSystem の対象として区別することができるようになります。
startHeadPositionMode
ではユーザーの頭の位置にモデルを固定するモードを開始しています。詳細については後述します。
var body: some View {
RealityView { content in
do {
let targetContent = try await Entity(named: "ToyBiplane", in: realityKitContentBundle)
targetContent.scale = SIMD3<Float>(x: 1, y: 1, z: 1)
target.addChild(targetContent)
followRoot.components.set(FollowComponent())
content.add(followRoot)
content.add(headAnchorRoot)
startHeadPositionMode(content: content)
} catch {
fatalError("No entity to load")
}
以下では、 update
に toggleMode
を指定することで、モードの切り替えを監視するようにしています。
} update: { content in
toggleMode(content: content)
}
以下では onDisappear
メソッドで、スペースが破棄された際に targetContent
を破棄しています。
.onDisappear {
if let targetContent = target.children.first {
target.removeChild(targetContent)
}
}
以下では、 startFollowMode
メソッドを実装しています。
このメソッドが実行されると、モデルがユーザーの位置に追従するモードに移行します。
このメソッドでは以下のステップで処理が行われています。
詳しくはコメントの通りです。
- 不要なアンカーの削除 : 頭部アンカーを削除し、シーンを整理。
- followRoot の配置 : 視界の前方に followRoot を配置。
- ToyBiplane の向き調整 : 自然な視点で表示されるように回転。
- 階層構造の設定 : followRoot の子として ToyBiplane を追加し、位置を設定。
extension FollowImmersiveView {
func startFollowMode() {
// headAnchor は FollowMode では使用しないため、アンカーを削除
guard let headAnchor = headAnchorRoot.children.first(where: { $0.name == "headAnchor" }) else { return }
headAnchorRoot.removeChild(headAnchor)
// ユーザーの視界の前方に followRoot を配置
followRoot.setPosition([0, 1, -1], relativeTo: nil)
// モデルの回転を調節して、良い向きで表示されるように変更
let orientation = simd_quatf(angle: .pi * -0.15, axis: [0, 1, 0]) * simd_quatf(angle: .pi * 0.2, axis: [1, 0, 0])
target.transform.rotation = orientation
// followRoot の子要素に target を追加
// followRoot には FollowComponent が付与されているため、FollowSystem の挙動に従うようになる
followRoot.addChild(target)
// target の位置を followRoot の位置と相対的に決定
target.setPosition([0.4, 0.2, -1], relativeTo: followRoot)
}
簡単に図にすると以下のようになるかなと思います。
followRoot
はユーザーの視界の前にあり、 FollowSystem
に従っているため、ユーザーの動きに応じて位置が変化する
target
は followRoot
の子要素として定義されているため、 followRoot
と同じように移動する。target
の位置は followRoot
に対して相対的な位置に設定されている
以下では、モデルをユーザーの位置に固定する場合の startHeadPositionMode
を実装しています。
AnchorEntity(.head)
で頭の位置を取得できます。一度だけ位置を取得したら、そのアンカーを headAnchorRoot
の子要素として追加しています。
headPositionedEntitiesRoot
の子要素に target
を指定し、位置を調整したのち、その headPositionedEntitiesRoot
を headAnchor
の子要素に指定しています。
これで、頭の位置のアンカーにモデルを表示できるようになります。
func startHeadPositionMode(content: RealityViewContent) {
target.transform.rotation = simd_quatf()
let headAnchor = AnchorEntity(.head)
headAnchor.anchoring.trackingMode = .once
headAnchor.name = "headAnchor"
headAnchorRoot.addChild(headAnchor)
headPositionedEntitiesRoot.addChild(target)
target.setPosition([0, 0, -0.15], relativeTo: headPositionedEntitiesRoot)
headAnchor.addChild(headPositionedEntitiesRoot)
headPositionedEntitiesRoot.setPosition([0, 0, -0.6], relativeTo: headAnchor)
}
以下では、モデルを表示させるモードの切り替えを行なっています。
appModel
に定義されているモードを切り替え、それと同時に切り替えたモードをスタートするようにしています。
func toggleMode(content: RealityViewContent) {
switch appModel.headTrackState {
case .follow:
startFollowMode()
case .headPosition:
startHeadPositionMode(content: content)
}
}
これで、View の実装は完了です。
4. App の実装
次に App の実装を行います。
コードは以下の通りです。
基本的には、先ほど実装した TogglePanel
や FollowImmersiveView
を表示できるように設定しているだけです。
ただ、 init
メソッドの中で FollowSystem
と FollowComponent
の設定を行なっています。 System や Component はこのように使用する前に登録しておく必要があります。
import SwiftUI
@main
struct HeadPositionApp: App {
@State private var appModel: HeadPositionAppModel = HeadPositionAppModel()
@Environment(\.openImmersiveSpace) var openImmersiveSpace
init() {
FollowSystem.registerSystem()
FollowComponent.registerComponent()
}
var body: some Scene {
WindowGroup {
TogglePanel()
.environment(appModel)
}
.defaultSize(CGSize(width: 400, height: 200))
ImmersiveSpace(id: "FollowImmersiveView") {
FollowImmersiveView()
.environment(appModel)
}
}
}
これで実行すると、以下のページにあるように、ユーザーの頭の位置に合わせてモデルの位置が変わることがわかります。
まとめ
最後まで読んでいただいてありがとうございました。
決まったアニメーションを実行する場合は今回のような実装は必要ありませんが、ユーザーの位置に合わせたアニメーションを実行することで、より没入感のある体験が実現できるかと思います。
誤っている点やもっと良い書き方があればご指摘いただければ幸いです。
参考
Discussion