Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Space Time Attack: ハンドトラッキングでスペースシップを操縦する空間ゲーム

TAAT
November 27, 2024

Space Time Attack: ハンドトラッキングでスペースシップを操縦する空間ゲーム

visionOS Engineer Meetup vol.8でLT登壇させていただいた発表資料です
https://visionos-engineer.connpass.com/event/337861/

こちらの記事もご参照ください

Apple Vision Pro向けのハンドトラッキングを活用した空間ゲームをリリースしました
https://note.com/taatn0te/n/n82730bc09ddf

Reality Composer ProのShader Graph Materialで少しリッチの表現に
https://note.com/taatn0te/n/necfe1206a602

TAAT

November 27, 2024
Tweet

Other Decks in Technology

Transcript

  1. きっかけ Immersive Space WWDC 2024 Discover RealityKit APIs for iOS,

    macOS, and visionOSという セッションとそ サンプルコード を動かしてみたら、ハンドトラッキングだけでスペース シップを操縦する がとても新感覚で面白かった で、こ ハンドトラッキング操作とタ イムアタック要素を組み合わせてSpace Time Attackを開発
  2. 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") } ... } }
  3. 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モデルを非同期に読み込める
  4. 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に追加する
  5. 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 } 他にもスペースシップをコントロールするため 様々なコンポーネントを設定
  6. Shader Graphを使ってチェックポイントをリッチに表現 Shader Graph Reality Composer Proで3Dコンテン ツ用 マテリアルやエフェクトをノードベースで構築でき る機能で、いろんなノードを組み合わせて複雑でリッチ

    な表現ができる 今回 Shader Graphを使って、視線 角度によって表 面上 反射率が変わるフレネル効果 を実装 Reality Composer Pro Shader Graph Materialで少しリッチ 表現に
  7. ECS構成 RealityKitで ECS (Entity Component System)というデータ指向 設計パターンを 採用していて、オブジェクトに状態や動作を追加できる Entity RealityKitシーンにおける要素

    単位 Component Entityに追加することで状態やデータを持たせる System クエリで対象 Entityを効率的に検索し、フレームごとに対象 Entity に振る舞いを定義して、動作やロジックを実装できる Build spatial experiences with RealityKit
  8. ShipFlightSystem ShipControlSystem ShipVisualsSystem ShipAudioSystem PrimaryThrust Component PitchRoll Component Throttle Component

    ShipFlightState Component ShipFlight Component Collision Component PhysicsMotion Component ShipVisuals Component ShipAudio Component ShipControl Component 機体 飛行時 姿勢や 推進力を制御するシステム 機体 制御に必要なパラメータを 管理・更新するシステム エンジン出力 パーティクル表示 やオーディオを制御するシステム
  9. 飛行時 姿勢や推進力 コントロール 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) } } }
  10. 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) } } } }
  11. Throttleでエンジン音 オーディオをコントロール AudioSource- EngineExhaust AudioSource- EngineExhaust AudioFileResource SpaceshipAudioStorage AudioPlay backController

    play() LeftEngine RightEngine prepareAudio() AudioPlay backController オーディオ再生用 Entityでオー ディオファイルを準備し、コント ローラから再生させる
  12. 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 } } } }
  13. 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
  14. ハンドトラッキング処理でパラメータを計算 AnchorEntity rightPalm AnchorEntity leftIndexFingerTip AnchorEntity leftThumbTip AnchorEntity leftPalm HandTrackingComponent

    ShipControlComponent HandTrackingComponent HandTrackingComponent HandTrackingComponent トラッキングしている複数 AnchorEntityからtransformを 取得してパラメータを計算する throttle: 指先や関節間 距離から計算 pitch, roll: 両手 ひら 位置や相対ベクトルから計算 ※ハンドトラッキングモードによって計算ロジック 異なる ※詳細な計算ロジック 省略するが、サンプルコード を参照 ShipControlParameters (throttle, pitch, roll)を更新 HandsShipControlProviderSystem
  15. ハンドトラッキング処理 流れ HandsShipControlProviderSystemでアンカーポイントをもとに計算されたパラメータ がShipControlSystem経由で更新され、最終的にShipFlightSystemで機体 姿勢 や推進力 コントロールで使われる AnchorEntities HandTracking Component

    HandsShipControl ProviderSystem ShipControl System ShipFlightSystem ShipControl Component Throttle Component ShipFlight Component PitchRoll Component ハンドトラッキング処理で計算されたパラメー タをそれぞれ コンポーネントに渡す 更新されたパラメータをコンポーネントから取得して、機体 姿勢や推 進力をコントロールする
  16. おまけ 今回 空間ゲームで 、スペースシップ 3Dモデル Low-Poly Spaceships Setとい うアセットを利用したが、実 Luma

    AI GenieでAI生成にもトライしたが、細部がうまく生 成されなかった で断念。一方、アイコン AI生成したも をFront, Backに切り出して 利用できた。
  17. 参考 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で ハンドジェスチャ実装に関する調査
  18. カウントダウン表示 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) } } } }
  19. 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 } }
  20. フレネル効果 視線ベクトル 法線ベクトル 内積 時間経過で累乗 指数を 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]))
  21. 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 } }