🥽

【Swift】visionOSのカスタムジェスチャーで玉を飛ばす

2024/03/04に公開

はじめに

visionOSは基本的にハンドトラッキングとアイトラッキングで操作を行います。
Appleの公式では以下のように機能するよう設計されています。

そして、上記以外の入力もカスタムジェスチャーを用いてオブジェクトを操作することができます。
今回はカスタムジェスチャーを用いて現実世界に仮想の玉を飛ばしてみました!

つくったもの

ARKitSessionHandTrackingProviderを使って銃を撃つジェスチャーから玉を飛ばしてみました。
また、後述のSceneReconstructionProviderを使って実世界をスキャンし、玉が現実世界に存在するようにしました。

ARKitSessionの概要

ARKitSession

  • AR体験のセッションを管理するためのもの。

DataProvider

  • Anchorの変更やデータの更新のポーリングなどの観察を可能にするもの。

Anchor

  • 実世界の位置と方向を表すもの。

前提条件

  1. アプリはFullSpaceに入る必要があります。
  2. 一部のARKitデータにはアクセス権限が必要です。

ARKitSessionの実行

SessionはDataProviderのセットを提供することで実行します。
DataProviderはDataを受信します。

ARKitSessionの使い方

使用したいDataProviderをrunでつっこむだけ。

ImmersiveView.swift
RealityView { content in
    content.add(model.setupContentEntity())
}
.task {
    do {
        if model.dataProvidersAreSupported && model.isReadyToRun {
            try await model.session.run([model.sceneReconstruction, model.handTracking])
        } else {
            await dismissImmersiveSpace()
        }
    } catch {
        print("Failed to start session: \(error)")
        await dismissImmersiveSpace()
        openWindow(id: "error")
    }
}
ViewModel.swift
let session = ARKitSession()
let handTracking = HandTrackingProvider()
let sceneReconstruction = SceneReconstructionProvider()

SceneReconstructionProvider

SceneReconstructionProviderは周りの世界をスキャンするとともに細分化されたAnchorを提供します。

Anchorの取得

anchorUpdatesからanchorを取得してcollisionなどを設置しています。
これだけで衝突判定を持つMeshEntityを作成することができます。

ViewModel.swift
func processReconstructionUpdates() async {
    for await update in sceneReconstruction.anchorUpdates {
        let meshAnchor = update.anchor
        
        guard let shape = try? await ShapeResource.generateStaticMesh(from: meshAnchor) else { continue }
        switch update.event {
        case .added:
            let entity = ModelEntity()
            entity.transform = Transform(matrix: meshAnchor.originFromAnchorTransform)
            entity.collision = CollisionComponent(shapes: [shape], isStatic: true)
            entity.components.set(InputTargetComponent())
            
            entity.physicsBody = PhysicsBodyComponent(mode: .static)
            
            meshEntities[meshAnchor.id] = entity
            contentEntity.addChild(entity)
        case .updated:
            guard let entity = meshEntities[meshAnchor.id] else { continue }
            entity.transform = Transform(matrix: meshAnchor.originFromAnchorTransform)
            entity.collision?.shapes = [shape]
        case .removed:
            meshEntities[meshAnchor.id]?.removeFromParent()
            meshEntities.removeValue(forKey: meshAnchor.id)
        }
    }
}

HandTrackingProvider

HandTrackingProviderはそれぞれの手の骨格データを含むAnchorを提供します。

以下は、取得できる関節です。ただし、現在は名称が変わっています。

https://developer.apple.com/documentation/arkit/handskeleton/jointname

Anchorの取得

こちらもanchorUpdatesからanchorを取得しています。anchorからchiralityで左右を判別し、メソッドを実行します。

ViewModel.swift
func processHandUpdates() async {
    for await update in handTracking.anchorUpdates {
        switch update.event {
        case .updated:
            let anchor = update.anchor
            
            guard anchor.isTracked else { continue }
            
            if anchor.chirality == .left {
                latestHandTracking.left = anchor
                spawnSphereOnGunGesture(handAnchor: latestHandTracking.left)
            } else if anchor.chirality == .right {
                latestHandTracking.right = anchor
                spawnSphereOnGunGesture(handAnchor: latestHandTracking.right)
            }
        default:
            break
        }
    }
}

銃を撃つジェスチャーの計算

親指と人差指の根本のTransformを取得して4cm以下になったら手首のTransformを返します。

ViewModel.swift
// 銃を撃つポーズの計算
func detectGunGestureTransform(handAnchor: HandAnchor?) -> simd_float4x4? {
    guard let handAnchor = handAnchor else { return nil }
    guard
        let handThumbTip = handAnchor.handSkeleton?.joint(.thumbTip),
        let handIndexFingerKnuckle = handAnchor.handSkeleton?.joint(.indexFingerKnuckle),
        handThumbTip.isTracked &&
            handIndexFingerKnuckle.isTracked
    else {
        return nil
    }
    
    let originFromHandThumbTipTransform = matrix_multiply(
        handAnchor.originFromAnchorTransform, handThumbTip.anchorFromJointTransform
    ).columns.3.xyz
    
    let originFromHandIndexFingerKnuckleTransform = matrix_multiply(
        handAnchor.originFromAnchorTransform, handIndexFingerKnuckle.anchorFromJointTransform
    ).columns.3.xyz
    
    let thumbToIndexFingerDistance = distance(
        originFromHandThumbTipTransform,
        originFromHandIndexFingerKnuckleTransform
    )
    
    // 親指と人差し指の根本が接触しているか判断
    if thumbToIndexFingerDistance < 0.04 { // 接触していると見なす距離の閾値
        return handAnchor.originFromAnchorTransform
    } else {
        return nil
    }
}

玉の生成

玉を生成し手首から指先までのオフセット+力を加える方向を計算してaddForceしています。
(本当はHandAnchorのEntityの子に球体のEntityを生成して前に飛ばすようにしたかったけど、難しかった。)

func spawnSphereOnGunGesture(handAnchor: HandAnchor?) {
    guard let handAnchor = handAnchor,
          let handLocation = detectGunGestureTransform(handAnchor: handAnchor) else { return }
    // 球体のModelEntity
    let entity = ModelEntity(
        mesh: .generateSphere(radius: 0.05),
        materials: [SimpleMaterial(color: .white, isMetallic: true)],
        collisionShape: .generateSphere(radius: 0.05),
        mass: 1.0
    )
    
    // 球体を生成する位置
    entity.transform.translation = Transform(matrix: handLocation).translation + calculateTranslationOffset(handAnchor: handAnchor)
    // 球体を飛ばす方向
    let forceDirection = calculateForceDirection(handAnchor: handAnchor)
    entity.addForce(forceDirection * 300, relativeTo: nil)
    // 球体をcontentEntityの子として追加
    contentEntity.addChild(entity)
}

// 指の長さに相当するoffsetを定義
func calculateTranslationOffset(handAnchor: HandAnchor) -> SIMD3<Float> {
    let handRotation = Transform(matrix: handAnchor.originFromAnchorTransform).rotation
    return handRotation.act(handAnchor.chirality == .left ? SIMD3(0.25, 0, 0) : SIMD3(-0.25, 0, 0))
}

// 手の向きに基づいて力を加える方向を計算
func calculateForceDirection(handAnchor: HandAnchor) -> SIMD3<Float> {
    let handRotation = Transform(matrix: handAnchor.originFromAnchorTransform).rotation
    return handRotation.act(handAnchor.chirality == .left ? SIMD3(1, 0, 0) : SIMD3(-1, 0, 0))
}

すべてのコード

https://github.com/raisukeshirabe/arkit-session-example
※実機のみ動作します

参考

https://developer.apple.com/videos/play/wwdc2023/10082/
https://developer.apple.com/documentation/visionos/incorporating-real-world-surroundings-in-an-immersive-experience
https://developer.apple.com/documentation/visionos/happybeam#Support-several-kinds-of-input

終わりに

今回はカスタムジェスチャーで玉を飛ばすサンプルアプリを作成しました。
カスタムジェスチャーを使うことでさらにvisionOSの幅が広がり魔法や忍術のような漫画の世界を実現できそうだなと感じました!

今後もvisionOSについて発信していきますので、この記事が参考になったと思ったらぜひ♡をお願いします。

Discussion