Slide 1

Slide 1 text

ハンドトラッキングで スペースシップを操縦する空間ゲーム visionOS Engineer Meetup vol.8 TAAT

Slide 2

Slide 2 text

No content

Slide 3

Slide 3 text

VRからスマホまでどこからでも遊べる メタバースプラットフォーム

Slide 4

Slide 4 text

目次 1. アプリ 紹介 2. 全体的な構成と遷移 3. WindowやImmersive Spaceに3Dコンテンツを表示 4. ECS構成と機体 コントロール 5. ハンドトラッキング処理

Slide 5

Slide 5 text

Space Time Attack ハンドトラッキングでスペースシップを 操縦しながらチェックポイントを通過する タイムアタック 空間ゲーム Available on the App Store

Slide 6

Slide 6 text

No content

Slide 7

Slide 7 text

きっかけ Immersive Space WWDC 2024 Discover RealityKit APIs for iOS, macOS, and visionOSという セッションとそ サンプルコード を動かしてみたら、ハンドトラッキングだけでスペース シップを操縦する がとても新感覚で面白かった で、こ ハンドトラッキング操作とタ イムアタック要素を組み合わせてSpace Time Attackを開発

Slide 8

Slide 8 text

目次 1. アプリ 紹介 2. 全体的な構成と遷移 3. WindowやImmersive Spaceに3Dコンテンツを表示 4. ECS構成と機体 コントロール 5. ハンドトラッキング処理

Slide 9

Slide 9 text

全体的な構成と遷移 TitleWindow GameWindow Immersive Space dismissWindow & openWindow dismissImmersiveSpace openImmersiveSpace dismissWindow & openWindow

Slide 10

Slide 10 text

WindowやImmersive Space 開閉 struct TitleView: View { // WindowやImmersive Space 開閉アクションを環境変数として取得 @Environment(\.openWindow) var openWindow @Environment(\.dismissWindow) var dismissWindow @Environment(\.openImmersiveSpace) private var openImmersiveSpace ... var body: some View { ... Button("PLAY") { // id指定でImmersive Spaceを開く await openImmersiveSpace(id: "ImmersiveSpace") // タイトルウィンドウを閉じて、ゲームウィンドウを開く dismissWindow(id: "TitleWindow") openWindow(id: "GameWindow") } ... } }

Slide 11

Slide 11 text

目次 1. アプリ 紹介 2. 全体的な構成と遷移 3. WindowやImmersive Spaceに3Dコンテンツを表示 4. ECS構成と機体 コントロール 5. ハンドトラッキング処理

Slide 12

Slide 12 text

Windowに3Dモデルを表示する Model3D(named: shipModel.name, bundle: realityKitContentBundle) { model in model .resizable() .scaledToFit() .rotation3DEffect(.init(degrees: -90), axis: (x: 1, y: 0, z: 0)) // モデル 向きを調整 } placeholder: { ProgressView() } .onTapGesture {...} .hoverEffect() // 視線を合わせた時 エフェクト Window内に3Dモデルを表示したい場合 、 Viewに準拠したModel3Dを使う USDやRealityファイル、指定したURLから 3Dモデルを非同期に読み込める

Slide 13

Slide 13 text

Immersive Spaceに3Dコンテンツを表示する // ImmersiveView RealityView { content in let entity = Entity() viewModel.rootEntity = entity content.add(entity) ... } // ImmersiveViewModel func createEntity() { let color: SimpleMaterial.Color = .init(red: 1, green: 0, blue: 0, alpha: 1) let material = SimpleMaterial(color: color, roughness: 1, isMetallic: false) let entity = ModelEntity(mesh: .generateSphere(radius: 1.0), materials: [material]) entity.components.set(SomeComponent()) rootEntity?.addChild(entity) } RealityViewで リッチな3Dコンテンツを 表示でき、makeクロージャーでコンテンツ 初期化を行うが、rootEntity みを追加 してViewModelに保持させる ViewModelで必要に応じてEntityを 構築してrootEntityに追加する

Slide 14

Slide 14 text

Immersive Spaceにスペースシップを表示する // ImmersiveViewModel func createShip() async throws -> ModelEntity { let ship = ModelEntity() // Reality Composer Proで作成したシーンからEntityを取得する let shipModel = try await Entity(named: "Ship", in: realityKitContentBundle) ship.addChild(shipModel) // 物理的な振る舞いで必要なコンポーネントを設定 var physicsBody = PhysicsBodyComponent(mode: .dynamic) ship.components.set(physicsBody) // 衝突判定や力を加えるため コンポーネントを設定 let bodyCollisionShape = ShapeResource.generateSphere(radius: 0.08) ship.components.set(CollisionComponent(shapes: [bodyCollisionShape])) ship.components.set(PhysicsMotionComponent()) ... return ship } 他にもスペースシップをコントロールするため 様々なコンポーネントを設定

Slide 15

Slide 15 text

Shader Graphを使ってチェックポイントをリッチに表現 Shader Graph Reality Composer Proで3Dコンテン ツ用 マテリアルやエフェクトをノードベースで構築でき る機能で、いろんなノードを組み合わせて複雑でリッチ な表現ができる 今回 Shader Graphを使って、視線 角度によって表 面上 反射率が変わるフレネル効果 を実装 Reality Composer Pro Shader Graph Materialで少しリッチ 表現に

Slide 16

Slide 16 text

目次 1. アプリ 紹介 2. 全体的な構成と遷移 3. WindowやImmersive Spaceに3Dコンテンツを表示 4. ECS構成と機体 コントロール 5. ハンドトラッキング処理

Slide 17

Slide 17 text

ECS構成 RealityKitで ECS (Entity Component System)というデータ指向 設計パターンを 採用していて、オブジェクトに状態や動作を追加できる Entity RealityKitシーンにおける要素 単位 Component Entityに追加することで状態やデータを持たせる System クエリで対象 Entityを効率的に検索し、フレームごとに対象 Entity に振る舞いを定義して、動作やロジックを実装できる Build spatial experiences with RealityKit

Slide 18

Slide 18 text

ShipFlightSystem ShipControlSystem ShipVisualsSystem ShipAudioSystem PrimaryThrust Component PitchRoll Component Throttle Component ShipFlightState Component ShipFlight Component Collision Component PhysicsMotion Component ShipVisuals Component ShipAudio Component ShipControl Component 機体 飛行時 姿勢や 推進力を制御するシステム 機体 制御に必要なパラメータを 管理・更新するシステム エンジン出力 パーティクル表示 やオーディオを制御するシステム

Slide 19

Slide 19 text

飛行時 姿勢や推進力 コントロール ShipFlightSystemで ThrottleComponent, PitchRollComponentからthrottle, pitch, roll 値を取得して姿勢や推進力を計算し、entity.transform.rotationで姿勢を更新、 addForceで推進力を与える

Slide 20

Slide 20 text

飛行時 姿勢や推進力 コントロール final class ShipFlightSystem: System { // Entityを抽出するため クエリ static let query = EntityQuery(where: (.has(ShipFlightComponent.self) && .has(ThrottleComponent.self) && .has(PitchRollComponent.self))) // シーン更新時に呼 れる func update(context: SceneUpdateContext) { for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) { // Componentからパラメータを取得 let throttle = entity.components[ThrottleComponent.self]!.throttle let pitchRoll = entity.components[PitchRollComponent.self]! // pitch, rollから姿勢を計算して更新する var flightState = entity.components[ShipFlightStateComponent.self]! … entity.transform.rotation = (flightState.yaw * flightState.pitchRoll).normalized // Entityが物理的な振る舞いを行うコンポーネントを持つ場合、throttleから推進力を計算して与える guard let physicsEntity = entity as? HasPhysics else { return } ... physicsEntity.addForce(force, relativeTo: nil) } } }

Slide 21

Slide 21 text

Throttleでエンジン出力 パーティクルをコントロール ShipVisualsSystemで ThrottleComponentからthrottle 値を取得して、予め左右 エンジンに配置されたParticleEmitter 値を調整することで、throttleに応じてパー ティクルを変化させる RightEngine ParticleEmitter LeftEngine ParticleEmitter ParticleEmitter Component ParticleEmitter Component

Slide 22

Slide 22 text

Throttleでエンジン出力 パーティクルをコントロール final class ShipVisualsSystem: System { static let query = EntityQuery(where: .has(ThrottleComponent.self)) func update(context: SceneUpdateContext) { for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) { guard let throttle = entity.components[ThrottleComponent.self]?.throttle else { return } for engineName in ["LeftEngine", "RightEngine"] { guard let engine = entity.findEntity(named: engineName), // LeftEngine,RightEngineそれぞれ ParticleEmitterを取得 let particles = engine.findEntity(named: "ParticleEmitter") else { return } // throttle 値に応じてパーティクルエフェクトを調整 var particleEmitter = vaporTrail.components[ParticleEmitterComponent.self]! particleEmitter.isEmitting = throttle > 0.1 particleEmitter.mainEmitter.lifeSpan = Double(throttle) * 0.25 particles.components.set(particleEmitter) } } } }

Slide 23

Slide 23 text

Throttleでエンジン音 オーディオをコントロール ShipAudioSystemで ShipVisualsSystemと同様に、ThrottleComponentから throttle 値を取得して、予め左右 エンジンに配置されたオーディオ再生用 Entity オーディオ出力を変化させる

Slide 24

Slide 24 text

Throttleでエンジン音 オーディオをコントロール AudioSource- EngineExhaust AudioSource- EngineExhaust AudioFileResource SpaceshipAudioStorage AudioPlay backController play() LeftEngine RightEngine prepareAudio() AudioPlay backController オーディオ再生用 Entityでオー ディオファイルを準備し、コント ローラから再生させる

Slide 25

Slide 25 text

Throttleでエンジン音 オーディオをコントロール final class ShipAudioSystem: System { static let query = EntityQuery(where: (.has(ShipAudioComponent.self) && .has(ThrottleComponent.self))) func update(context: SceneUpdateContext) { for entity in context.entities(matching: Self.query, updatingSystemWhen: .rendering) { let throttle = entity.components[ThrottleComponent.self]!.throttle for engineName in ["LeftEngine", "RightEngine"] { guard let engine = entity.findEntity(named: engineName), // LeftEngine,RightEngineそれぞれ オーディオ再生用 Entityを取得 let exhaust = engine.findEntity(named: "AudioSource-EngineExhaust") else { return } // throttle 値に応じてオーディオ ゲインを調整 let gain = decibels(amplitude: Double(throttle)) exhaust.components[SpatialAudioComponent.self]!.gain = gain } } } }

Slide 26

Slide 26 text

目次 1. アプリ 紹介 2. 全体的な構成と遷移 3. WindowやImmersive Spaceに3Dコンテンツを表示 4. ECS構成と機体 コントロール 5. ハンドトラッキング処理

Slide 27

Slide 27 text

final class HandsShipControlProviderSystem: System { let session = SpatialTrackingSession() init(scene: Scene) { Task { @MainActor in let config = SpatialTrackingSession.Configuration(tracking: [.hand]) _ = await session.run(config) } } } // RealityView 初期化時に、アンカーポイントを生成して追加する static func makeHandTrackingEntities() -> Entity { let container = Entity() container.name = "HandTrackingEntitiesContainer" let leftIndexFingerTip = AnchorEntity(.hand(.left, location: .indexFingerTip)) container.addChild(leftIndexFingerTip) ... return container } ハンドトラッキング処理 準備 AnchoringComponent.Target.HandLocationや HandJointで手 アンカーポイントを指定できる SpatialTrackingSession (visionOS 2.0+) を使え 、簡単にハンドトラッキングを実現 できる visionOS_2_30Days / Day7_HandTracking

Slide 28

Slide 28 text

ハンドトラッキング処理でパラメータを計算 AnchorEntity rightPalm AnchorEntity leftIndexFingerTip AnchorEntity leftThumbTip AnchorEntity leftPalm HandTrackingComponent ShipControlComponent HandTrackingComponent HandTrackingComponent HandTrackingComponent トラッキングしている複数 AnchorEntityからtransformを 取得してパラメータを計算する throttle: 指先や関節間 距離から計算 pitch, roll: 両手 ひら 位置や相対ベクトルから計算 ※ハンドトラッキングモードによって計算ロジック 異なる ※詳細な計算ロジック 省略するが、サンプルコード を参照 ShipControlParameters (throttle, pitch, roll)を更新 HandsShipControlProviderSystem

Slide 29

Slide 29 text

ハンドトラッキング処理 流れ HandsShipControlProviderSystemでアンカーポイントをもとに計算されたパラメータ がShipControlSystem経由で更新され、最終的にShipFlightSystemで機体 姿勢 や推進力 コントロールで使われる AnchorEntities HandTracking Component HandsShipControl ProviderSystem ShipControl System ShipFlightSystem ShipControl Component Throttle Component ShipFlight Component PitchRoll Component ハンドトラッキング処理で計算されたパラメー タをそれぞれ コンポーネントに渡す 更新されたパラメータをコンポーネントから取得して、機体 姿勢や推 進力をコントロールする

Slide 30

Slide 30 text

ハンドトラッキングモード 二種類 ハンドトラッキングモードを用意 Pinch & Tilt 左手でピンチ、右手を傾けるシンプルなモード Thumbs-up & Steering 左手でサムズアップ、両手で飛行機 ステアリングを操 作するようなモード

Slide 31

Slide 31 text

ハンドトラッキングモードごと アンカーポイント Pinch & Tilt Thumbs-up & Steering Pinch Thumbs-up

Slide 32

Slide 32 text

ハンドトラッキングモードごと 動き Pinch & Tilt Thumbs-up & Steering

Slide 33

Slide 33 text

おまけ 今回 空間ゲームで 、スペースシップ 3Dモデル Low-Poly Spaceships Setとい うアセットを利用したが、実 Luma AI GenieでAI生成にもトライしたが、細部がうまく生 成されなかった で断念。一方、アイコン AI生成したも をFront, Backに切り出して 利用できた。

Slide 34

Slide 34 text

参考 Discover RealityKit APIs for iOS, macOS, and visionOS Creating a Spaceship game Meet ARKit for spatial computing Apple Vision Pro向け ハンドトラッキングを活用した空間ゲームをリリースしました Reality Composer Pro Shader Graph Materialで少しリッチ 表現に visionOSで鬼をカメラ 方向へ動かす visionOSで ハンドジェスチャ実装に関する調査

Slide 35

Slide 35 text

付録

Slide 36

Slide 36 text

カウントダウン表示 struct CountDownRing: View { let seconds: Int @Binding var count: Int @State private var progress: CGFloat = 1 var body: some View { ZStack { Circle() .stroke(lineWidth: 15) .frame(width: 160, height: 160) .foregroundStyle(.gray.opacity(0.3)) Circle() .trim(from: 0, to: progress) // 0から残り秒数までをトリミングする .stroke(style: StrokeStyle(lineWidth: 18, lineCap: .round, lineJoin: .round)) .frame(width: 160, height: 160) .foregroundStyle(.white) .rotationEffect(.degrees(-90)) // 縦方向からアニメーションさせるために、反時計回りに 90°回転させる Text("\(count)") .font(.system(size: 48, weight: .bold)) .monospacedDigit() } .onChange(of: count) { _, newValue in withAnimation(.easeOut(duration: 0.5)) { progress = CGFloat(newValue) / CGFloat(seconds) } } } }

Slide 37

Slide 37 text

Immersive Spaceにskyboxを表示する func createSkybox(name: String) -> Entity? { // 充分に大きな半径で球体メッシュを生成 let sphere = MeshResource.generateSphere(radius: 1000) var material = UnlitMaterial() do { let texture = try TextureResource.load(named: name) material.color = .init(texture: .init(texture)) let entity = Entity() entity.components.set(ModelComponent(mesh: sphere, materials: [material])) // テクスチャを内側にするためにマイナスに entity.scale = .init(x: -1, y: 1, z: 1) return entity } catch { return nil } }

Slide 38

Slide 38 text

フレネル効果 視線ベクトル 法線ベクトル 内積 時間経過で累乗 指数を 1~2 範囲で変化させる 指定した2色を 重み付けして ミックスさせる // name 対象マテリアルへ フルパス let material = try? await ShaderGraphMaterial(named: "/Root/UnvisitedCheckPoint/UnvisitedMaterial", from: "Materials/CheckPointMaterial", in: realityKitContentBundle) ?? SimpleMaterial(...) entity.components.set(ModelComponent(mesh: .generateSphere(radius: checkPointRadius), materials: [material]))

Slide 39

Slide 39 text

HandLocation, HandJoint AnchoringComponent.Target.HandLocation wrist, palm, indexFingerTip, thumbTip, aboveHandを指定してトラッキングできる AnchoringComponent.Target.HandLocation.HandJoint 細かい関節も指定できる

Slide 40

Slide 40 text

HandsShipControlProviderSystem final class HandsShipControlProviderSystem: System { let session = SpatialTrackingSession() ... func update(context: SceneUpdateContext) { var transforms: [HandTrackingComponent.Location: simd_float4x4] = [:] // HandTrackingComponentを持つentities (AnchorEntity)からtransformを取得して保持する for entity in context.entities(matching: EntityQuery(where: .has(HandTrackingComponent.self)), updatingSystemWhen: .rendering) { guard let anchorEntity = entity as? AnchorEntity, anchorEntity.isAnchored else { continue } guard let handTrackingComponent = entity.components[HandTrackingComponent.self] else { continue } transforms[handTrackingComponent.location] = entity.transformMatrix(relativeTo: nil) } // ShipControlComponentを持つentitiesに対して、上で取得したtransformをもとにパラメータを更新 for entity in context.entities(matching: EntityQuery(where: .has(ShipControlComponent.self)), updatingSystemWhen: .rendering) { updateShipControlParameters(for: entity, context: context, transforms: transforms) } } ... @MainActor func updateShipControlParameters(for entity: Entity, context: SceneUpdateContext, transforms: [HandTrackingComponent.Location: simd_float4x4]) { let shipControlParameters = entity.components[ShipControlComponent.self]!.parameters guard let indexTipTransform = transforms[.leftIndexFingerTip], let thumbTipTransform = transforms[.leftThumbTip], let rightPalmTransform = transforms[.rightPalm] else { return } // 左手 親指,人差し指 transformからthrottleを計算して更新する let throttle = computeTargetThrottle(indexTipTransform: indexTipTransform, thumbTipTransform: thumbTipTransform) shipControlParameters.throttle = interpolateThrottle(current: currentThrottle, target: targetThrottle, deltaTime: context.deltaTime) // 左右 手 ひら transformからpitch,rollを計算して更新する let (pitch, roll) = computePitchAndRoll(leftPalmTransform: leftPalmTransform, rightPalmTransform: rightPalmTransform) shipControlParameters.pitch = pitch shipControlParameters.roll = roll } }