はじめに

こんにちは!システム開発部のYです。

Apple Vision Proでは、ハンドトラッキングとアイトラッキングを活用して操作を行います。
ハンドトラッキングには、標準で用意されているジェスチャに加えて、カスタムジェスチャを実装することも可能です。
今回は、ARKitを使用して複数のジェスチャを実装し、そのカスタマイズ方法についてまとめました。

SpatialTrackingSessionを利用してAnchorを取得する方法もありますが、
今回はARKitSessionとHandTrackingProviderを使用し、カスタムハンドジェスチャを実装します。

環境

  • Swift Version: 5.10
  • Xcode: 16.2
  • visionOS: 2.3
  • Reality Composer Pro: 2.0
  • macOS: 14.5

目次

  1. はじめに
  2. 環境
  3. 目次
  4. Apple VisionPro ハンドジェスチャについて
  5. デフォルトのハンドジェスチャについて
  6. カスタムハンドジェスチャについて
  7. 実装について
    1. ハンドトラッキングを検知する準備
      1. ハンドトラッキングを管理する HandTrackingModelの作成
      2. ImmersiveView.swift の変更
    2. 手をパーにするジェスチャ🖐️
      1. 使用する各関節の情報
      2. 手をパーにするジェスチャ検知
      3. 検証動画
    3. サムズアップジェスチャ👍
      1. 使用する各関節の情報
      2. サムズアップジェスチャの検知
      3. 検証動画
    4. 両手を使った指ハート🫶
      1. 使用する各関節の情報
      2. 両手を使った指ハートの検知
      3. 検証動画
  8. ヒューマンインターフェイスガイドライン
  9. まとめ
  10. 参考

Apple VisionPro ハンドジェスチャについて

ハンドジェスチャには2種類あります。
1つ目は、標準で用意されているデフォルトのハンドジェスチャ。
2つ目は、それ以外のカスタムハンドジェスチャです。
それぞれについて説明します。

デフォルトのハンドジェスチャについて

標準で用意されているデフォルトのハンドジェスチャには、以下の画像の通り「タップ」「ピンチ&ドラッグ」「ローテート」などがあります。
基本的には、これらのデフォルトのハンドジェスチャで十分な操作が可能ですが、アプリに独自性を持たせたい場合には、カスタムジェスチャを活用します。

カスタムハンドジェスチャについて

カスタムハンドジェスチャを認識するために、各関節データには以下のような名前が定義されています。
これらを基に指の動きや位置関係を分析することで、特定のジェスチャを認識する事が出来ます。

詳しい各関節の情報は下記リンクをご参照ください。

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

実装について

今回3種類のカスタムハンドジェスチャを実装したので、コードとともに説明します。

ハンドトラッキングを検知する準備

ハンドトラッキングを管理する HandTrackingModelの作成

まず、ハンドトラッキングを管理する HandTrackingModel を作成します。
このモデルでは、ARKitSessionとHandTrackingProviderを管理し、左右のハンドアンカーを保持します。
また、ARKitSessionを使ってハンドトラッキングを開始する処理もこちらで実装します。
さらに、detectionHandGestureで各ハンドジェスチャの検出を行います。

@Observable
@MainActor
class HandTrackingModel {

    let session = ARKitSession()
    
    let handTracking = HandTrackingProvider()
    
    var contentEntity = Entity()
    var latestHandTracking: HandsUpdates? = .init(left: nil, right: nil)
    
    var handGesture: HandGestureType = .None
    
    struct HandsUpdates {
        var left: HandAnchor?
        var right: HandAnchor?
    }
    
    func setupContentEntity() -> Entity {
        return contentEntity
    }
    
    // ハンドトラッキングがサポートされているかどうか
    var handTrackingProviderSupported: Bool {
        HandTrackingProvider.isSupported
    }
    
    var isReadyToRun: Bool {
        handTracking.state == .initialized || handTracking.state == .paused
    }
    
    ///  ハンドトラッキングを開始する
    func startHandTracking() async {
        do {
            if self.handTrackingProviderSupported && self.isReadyToRun {
                try await session.run([handTracking])
            } else {
                fatalError("ハンドトラッキングがサポートされていないか、実行準備ができてない")
            }
        } catch {
            fatalError("session run 失敗: \(error)")
        }
    }

    /// ARKitからハンドトラッキング情報を更新する
    func processHandUpdated() async {
        // 毎回呼ばれる
        for await update in handTracking.anchorUpdates {
            
            switch update.event {
            case .updated:
                let anchor = update.anchor
                
                if anchor.chirality == .left {
                    latestHandTracking?.left = anchor
                } else {
                    latestHandTracking?.right = anchor
                }

                // 認識したHandAnchorを使用してジェスチャを認識
                detectionHandGesture(leftHandAnchor: latestHandTracking?.left, rightHandAnchor: latestHandTracking?.right) 
            default:
                break
            }
        }
    }

    /// 各ハンドジェスチャの検出
    func detectionHandGesture(leftHandAnchor: HandAnchor?, rightHandAnchor: HandAnchor?) {
        
        if detectHandGesture(handAnchor: rightHandAnchor) {
            // 右手をパーにするジェスチャ
        } else if detectThumsUpGesture(handAnchor: rightHandAnchor) {
            // 右手をサムズアップしたジェスチャを検知
            self.handGesture = .ThumbsUp
        } else if detectHeartGesture(leftHandAnchor: leftHandAnchor, rightHandAnchor: rightHandAnchor) {
            // 両手を使った指ハートジェスチャを検知
            self.handGesture = .heart
        } else {
            // どのジェスチャも検知されなかった場合
            self.handGesture = .None
        }
    }
}

ImmersiveView.swift の変更

画面表示時に、taskを使ってハンドトラッキングを開始し、processHandUpdatedを呼び出してハンドトラッキングの検知を監視します。

import SwiftUI
import RealityKit
import RealityKitContent

struct ImmersiveView: View {

    @Environment(HandTrackingModel.self) var model
    
    var body: some View {
        RealityView { content in
            content.add(model.setupContentEntity())
        }
        .task {
            await model.startHandTracking()
        }
        .task {
            await model.processHandUpdated()
        }
    }
}

手をパーにするジェスチャ🖐️

使用する各関節の情報

使用する関節情報は、以下の通りです。

  • 3(親指の第一関節)
  • 7(人差し指の第二関節)
  • 12(中指の第二関節)
  • 17(薬指の第二関節)
  • 23(小指の第一関節)

手をパーにするジェスチャ検知

HandAnchorを使用して関節データを取得します。
HandAnchorのhandSkeletonとjoint を用いて、各関節のデータを取得し、
すべての関節がトラッキングされているかを確認します。

関節情報の親指と人差し指、人差し指と中指、中指と薬指、薬指と小指の距離を計算し、 それぞれの距離が一定以上離れているなら、手をパーにしていると判定します。

    /// 手をパーに広げるジェスチャを検出する
    func detectHandGesture(handAnchor: HandAnchor?) -> Bool {
        
        guard let handAnchor = handAnchor,
              handAnchor.isTracked else {
            return false
        }
        
        // 3(親指の第一関節), 7(人差し指の第二関節), 12(中指の第二関節), 17(薬指の第二関節), 23(小指の第一関節)
        guard
            // 3(親指の第一関節)
            let handThumbIntermediateTip = handAnchor.handSkeleton?.joint(.thumbIntermediateTip),
            // 7(人差し指)
            let handIndexFingerIntermediateBase = handAnchor.handSkeleton?.joint(.indexFingerIntermediateBase),
            // 12(中指)
            let handMiddleFingerIntermediateBase = handAnchor.handSkeleton?.joint(.middleFingerIntermediateBase),
            // 17(薬指)
            let handRingFingerIntermediateBase = handAnchor.handSkeleton?.joint(.ringFingerIntermediateBase),
            // 23(小指)
            let handLittleFingerIntermediateTip = handAnchor.handSkeleton?.joint(.littleFingerIntermediateTip),
            // すべての指がトラッキングされているかどうか
                handThumbIntermediateTip.isTracked &&
                handIndexFingerIntermediateBase.isTracked &&
                handMiddleFingerIntermediateBase.isTracked &&
                handRingFingerIntermediateBase.isTracked &&
                handLittleFingerIntermediateTip.isTracked
        else {
            return false
        }
        
        // 親指
        let originFromhandThumbIntermediateTipTransform = matrix_multiply(
            handAnchor.originFromAnchorTransform, handThumbIntermediateTip.anchorFromJointTransform
        ).columns.3.xyz
        
        // 人差し指
        let originFromHandIndexFingerIntermediateBaseTransform = matrix_multiply(
            handAnchor.originFromAnchorTransform, handIndexFingerIntermediateBase.anchorFromJointTransform
        ).columns.3.xyz
        
        // 中指
        let originFromHandMiddleFingerIntermediateBaseTransform = matrix_multiply(
            handAnchor.originFromAnchorTransform, handMiddleFingerIntermediateBase.anchorFromJointTransform
        ).columns.3.xyz
        
        // 薬指
        let originFromHandRingFingerIntermediateBaseTransform = matrix_multiply(
            handAnchor.originFromAnchorTransform, handRingFingerIntermediateBase.anchorFromJointTransform
        ).columns.3.xyz
        
        // 小指
        let originFromHandLittleFingerIntermediateTipTransform = matrix_multiply(
            handAnchor.originFromAnchorTransform, handLittleFingerIntermediateTip.anchorFromJointTransform
        ).columns.3.xyz
        
        // 親指と人差し指の距離計算
        let thumbToIndexFingerDistance = distance(
            originFromhandThumbIntermediateTipTransform,
            originFromHandIndexFingerIntermediateBaseTransform
        )
        let isThumbToindexFingerDistance = thumbToIndexFingerDistance > 0.05
                
        // 人差し指と中指の距離計算
        let indexFingerToMiddleFingerDistance = distance(
            originFromHandIndexFingerIntermediateBaseTransform,
            originFromHandMiddleFingerIntermediateBaseTransform
        )
        let isIndexFingerToMiddleFingerDistance = indexFingerToMiddleFingerDistance > 0.03
        
        // 中指と薬指の距離計算
        let middleFingerToRingFingerDistance = distance(
            originFromHandMiddleFingerIntermediateBaseTransform,
            originFromHandRingFingerIntermediateBaseTransform
        )
        let isMiddleFingerToRingFingerDistance = middleFingerToRingFingerDistance > 0.023
        
        // 薬指と小指の距離計算
        let ringFingerToLittleFingerDistance = distance(
            originFromHandRingFingerIntermediateBaseTransform,
            originFromHandLittleFingerIntermediateTipTransform
        )
        let isRingFingerToLittleFingerDistance = ringFingerToLittleFingerDistance > 0.027
        
        if isThumbToindexFingerDistance &&
            isIndexFingerToMiddleFingerDistance &&
            isMiddleFingerToRingFingerDistance &&
            isRingFingerToLittleFingerDistance {
            return true
        } else {
            return false
        }
    }

検証動画

こちらが検証動画になります。
カスタムジェスチャを認識し、表示されているViewに「Open Hand」と表示されていることが確認できます。

サムズアップジェスチャ👍

使用する各関節の情報

使用する関節情報は、以下の通りです。

  • 0(手首)
  • 4(親指の先端)
  • 7(人差し指の第2関節)
  • 9(人差し指の先端)
  • 14(中指の先端)
  • 19(薬指の先端)
  • 24(小指の先端)

サムズアップジェスチャの検知

他のジェスチャ同様HandAnchorを使用して関節データを取得します。
HandAnchorのhandSkeletonとjoint を用いて、各関節のデータを取得し、
すべての関節がトラッキングされているかを確認します。

まず、親指が人差し指の第一関節よりも上にあるかを計算し、親指を立てているかを判定します。
さらに、他の4本の指の位置を手首からの距離で計算し、指が折りたたまれているかを確認します。
これらの条件をすべて満たすと、サムズアップと判定されます。

    /// 手をサムズアップしたジェスチャを検出する
    func detectThumsUpGesture(handAnchor: HandAnchor?) -> Bool {
        
        guard let handAnchor = handAnchor,
              handAnchor.isTracked else {
            return false
        }
        
        // 0(手首), 4(親指の先端), 7(人差し指の第2関節)
        // 9(人差し指の先端), 14(中指の先端), 19(薬指の先端), 24(小指の先端)
        guard
            // 0(手首)
            let handWrist = handAnchor.handSkeleton?.joint(.wrist),
            // 4(親指の先端)
            let handThumbTip = handAnchor.handSkeleton?.joint(.thumbTip),
            // 7(人差し指の第2関節)
            let handIndexFingerIntermediateBase = handAnchor.handSkeleton?.joint(.indexFingerIntermediateBase),
                
            // 他の4本の指(人差し指、中指、薬指、小指)の先端
            // 9(人差し指の先端)
            let handIndexTip = handAnchor.handSkeleton?.joint(.indexFingerTip),
            // 14(中指の先端)
            let handMiddleTip = handAnchor.handSkeleton?.joint(.middleFingerTip),
            // 19(薬指の先端)
            let handRingTip = handAnchor.handSkeleton?.joint(.ringFingerTip),
            // 24(小指の先端)
            let handLittleTip = handAnchor.handSkeleton?.joint(.littleFingerTip),
            
            // すべての指がトラッキングされているかどうか
            handWrist.isTracked &&
            handThumbTip.isTracked &&
            handIndexFingerIntermediateBase.isTracked &&
            handIndexTip.isTracked &&
            handMiddleTip.isTracked &&
            handRingTip.isTracked &&
            handLittleTip.isTracked
        else {
            return false
        }
        
        // 各関節のワールド座標を取得
        // 手首
        let originFromHandWristTransform = matrix_multiply(
            handAnchor.originFromAnchorTransform, handWrist.anchorFromJointTransform
        ).columns.3.xyz
                
        // 親指の先端
        let originFromHandThumbTipTransform = matrix_multiply(
            handAnchor.originFromAnchorTransform, handThumbTip.anchorFromJointTransform
        ).columns.3.xyz
        
        // 人差し指の第2関節
        let originFromHandIndexIntermediateBaseTransform = matrix_multiply(
            handAnchor.originFromAnchorTransform, handIndexFingerIntermediateBase.anchorFromJointTransform
        ).columns.3.xyz
        
        // 人差し指の先端
        let originFromHandIndexTipTransform = matrix_multiply(
            handAnchor.originFromAnchorTransform, handIndexTip.anchorFromJointTransform
        ).columns.3.xyz
        
        // 中指の先端
        let originFromHandMiddleTipTransform = matrix_multiply(
            handAnchor.originFromAnchorTransform, handMiddleTip.anchorFromJointTransform
        ).columns.3.xyz
        
        // 薬指の先端
        let originFromHandRingTipTransform = matrix_multiply(
            handAnchor.originFromAnchorTransform, handRingTip.anchorFromJointTransform
        ).columns.3.xyz
        
        // 小指の先端
        let originFromHandLittleTipTransform = matrix_multiply(
            handAnchor.originFromAnchorTransform, handLittleTip.anchorFromJointTransform
        ).columns.3.xyz
        
        // サムズアップの判定
        
        // 親指が人差し指の第一関節よりも上にある
        let isThumbUp = distance(originFromHandThumbTipTransform, originFromHandIndexIntermediateBaseTransform) > 0.05
        
        // 他の4本の指が手首の近く(折りたたまれている)
        let isIndexBent = distance(originFromHandWristTransform, originFromHandIndexTipTransform) < 0.09
        let isMiddleBent = distance(originFromHandWristTransform, originFromHandMiddleTipTransform) < 0.09
        let isRingBent = distance(originFromHandWristTransform, originFromHandRingTipTransform) < 0.09
        let isLittleBent = distance(originFromHandWristTransform, originFromHandLittleTipTransform) < 0.09
        
        // 全ての条件を満たす場合、サムズアップと判定
        if isThumbUp && isIndexBent && isMiddleBent && isRingBent && isLittleBent {
            return true
        } else {
            return false
        }
    }

検証動画

こちらが検証動画になります。
カスタムジェスチャを認識し、表示されているViewに「Thumbs Up」と表示されていることが確認できます。

両手を使った指ハート🫶

使用する各関節の情報

使用する関節情報は、以下の通りです。

  • 8(人差し指の第1関節)
  • 9(人差し指の先端)
  • 14(中指の先端)

両手を使った指ハートの検知

両手を使用するため、左右のHandAnchorを使います。
他のジェスチャ同様に、HandAnchorにあるhandSkeletonのjointを用いてを用いて各関節のデータを取得し、すべての関節がトラッキングされているかを確認します。

まず、左手の人差し指の先端と、右手の人差し指の先端の距離を計算します。
さらに、ハートの窪み部分の形を判定するため、左手と右手の人差し指の第一関節の距離を計算します。
最後に、左手と右手の中指の先端の距離を計算し、すべての条件を満たした場合、指ハートと判定します。

    /// 両手を使った指ハートジェスチャを検出する
    private func detectHeartGesture(leftHandAnchor: HandAnchor?, rightHandAnchor: HandAnchor?) -> Bool {
        
        guard let leftHandAnchor = leftHandAnchor,
              let rightHandAnchor = rightHandAnchor,
              leftHandAnchor.isTracked, rightHandAnchor.isTracked else {
            return false
        }
        
        guard
            // 8(左手 人差し指の第1関節)
            let leftHandIndexFingerIntermediateBase = leftHandAnchor.handSkeleton?.joint(.indexFingerIntermediateTip),
            // 9(左手 人差し指の先端)
            let leftHandindexFingerTip = leftHandAnchor.handSkeleton?.joint(.indexFingerTip),
            // 14(左手 中指の先端)
            let leftHandMiddleFingerTip = leftHandAnchor.handSkeleton?.joint(.middleFingerTip),
            
            // 8(右手 人差し指の第1関節)
            let rightHandIndexFingerIntermediateBase = rightHandAnchor.handSkeleton?.joint(.indexFingerIntermediateTip),
            // 9(右手 人差し指の先端)
            let rightHandindexFingerTip = rightHandAnchor.handSkeleton?.joint(.indexFingerTip),
            // 14(右手 中指の先端)
            let rightHandMiddleFingerTip = rightHandAnchor.handSkeleton?.joint(.middleFingerTip),

            // すべての指がトラッキングされているかどうか
            leftHandIndexFingerIntermediateBase.isTracked &&
            leftHandindexFingerTip.isTracked &&
            leftHandMiddleFingerTip.isTracked &&
            rightHandindexFingerTip.isTracked &&
            rightHandMiddleFingerTip.isTracked else {
            
            return false
        }
        
        // 各関節のワールド座標を取得
        // 左手 第一関節
        let originFromLeftHandIndexFingerIntermediateBaseTransform = matrix_multiply(
            leftHandAnchor.originFromAnchorTransform, leftHandIndexFingerIntermediateBase.anchorFromJointTransform
        ).columns.3.xyz
        
        // 左手 人差し指の先端
        let originFromLeftHandIndexFingerTipTransform = matrix_multiply(
            leftHandAnchor.originFromAnchorTransform, leftHandindexFingerTip.anchorFromJointTransform
        ).columns.3.xyz
        
        // 左手 中指の先端
        let originFromLeftHandMiddleFingerTipTransform = matrix_multiply(
            leftHandAnchor.originFromAnchorTransform, leftHandMiddleFingerTip.anchorFromJointTransform
        ).columns.3.xyz
        
        // 右手 第一関節
        let originFromRightHandIndexFingerIntermediateBaseTransform = matrix_multiply(
            rightHandAnchor.originFromAnchorTransform, rightHandIndexFingerIntermediateBase.anchorFromJointTransform
        ).columns.3.xyz
        
        // 右手 人差し指の先端
        let originFromRightHandIndexFingerTipTransform = matrix_multiply(
            rightHandAnchor.originFromAnchorTransform, rightHandindexFingerTip.anchorFromJointTransform
        ).columns.3.xyz
        
        // 右手 中指の先端
        let originFromRightHandMiddleFingerTipTransform = matrix_multiply(
            rightHandAnchor.originFromAnchorTransform, rightHandMiddleFingerTip.anchorFromJointTransform
        ).columns.3.xyz
        
        // 指ハートを検出
        // 左手の人差し指の先端と、右手の人差し指の先端の、距離が近い場合を判定
        let indexFingersDistance = distance(originFromLeftHandIndexFingerTipTransform, originFromRightHandIndexFingerTipTransform)
        let isIndexFingersDistance = indexFingersDistance < 0.012
        // 左手の人差し指第一関節と、右手の人差し指第一関節の距離が近い場合を判定
        let originFromDistance = distance(originFromLeftHandIndexFingerIntermediateBaseTransform, originFromRightHandIndexFingerIntermediateBaseTransform)
        let isOriginFromDistance = originFromDistance < 0.042
        
        // 左手の中指の先端と、右手の中指の先端の距離が近い場合を判定
        let middleFingersDistance = distance(originFromLeftHandMiddleFingerTipTransform, originFromRightHandMiddleFingerTipTransform)
        let isMiddleFingersDistance = middleFingersDistance < 0.02
        
        if isIndexFingersDistance && isOriginFromDistance && isMiddleFingersDistance {
            return true
        } else {
            return false
        }
    }

検証動画

こちらが検証動画になります。
カスタムジェスチャを認識し、表示されているViewに「Heart」と表示されていることが確認できます。

ヒューマンインターフェイスガイドライン

重要なのは見つけやすく、簡単に実行でき、他のジェスチャと区別できることです。
また、アプリの重要な操作をジェスチャのみに依存しないようにすることも大切です。
詳しくは以下、ヒューマンインターフェイスガイドラインをご確認ください。

  • 見つけやすいこと
  • 簡単に実行できること
  • ほかのジェスチャと区別できること
  • アプリやゲームの重要な操作を行う唯一の手段にしないこと

https://developer.apple.com/jp/design/human-interface-guidelines/gestures

まとめ

ARKitでの、カスタムジェスチャの実装についてまとめました。
ハンドジェスチャには2種類あります。
1つ目は、標準で用意されているデフォルトのハンドジェスチャ。
2つ目は、それ以外のカスタムハンドジェスチャです。
カスタムジェスチャを活用することで、アプリに独自の操作方法を組み込むことができます。
関節データの位置や距離を分析することで、多様なカスタムジェスチャを認識することが可能です。
カスタムジェスチャは見つけやすく、簡単で、他と区別でき、
重要な操作をジェスチャのみに依存しないようにしましょう。
また、今回はカスタムジェスチャの認識にポイント間の距離のみを使用していますが、角度を加えた方が精度が向上すると思われます。
この記事が少しでもお役に立てれば幸いです。また、間違っている点があればご指摘いただけるとありがたいです。

参考



ギャップロを運営しているアップフロンティア株式会社では、一緒に働いてくれる仲間を随時、募集しています。 興味がある!一緒に働いてみたい!という方は下記よりご応募お待ちしております。
採用情報をみる