🫶

visionOSでのハンドジェスチャ実装に関する調査

2023/09/07に公開

概要

visionOS向けのアプリケーションでは、コントローラーが不要な仕様となっています。
基本的な入力はLook & Tap、つまり視線と手を用いて行われるため、特にハンドトラッキングが重要となっています。

今回はそんなvisionOS向けアプリでのハンドトラッキング、特にハンドジェスチャ認識の実装について調査した内容をまとめます。

ハンドジェスチャ実装の柔軟性についてAppleが言及していること

WWDC23の動画であるDesign for spatial inputで、ハンドトラッキングについても触れられていました。
https://developer.apple.com/videos/play/wwdc2023/10073/?time=741

デフォルトで使用できるジェスチャ
動画内では、基本的な動作は使用できるということ、そしてジェスチャのカスタムは可能であるという風に触れられていました。

現状visinOS向けアプリの開発方法としては、Unityを用いる場合Xcodeのみを用いる場合の2通りが提示されているため、それぞれでの実装に関してお話します。

Unityを用いる場合

UnityでvisionOS向けのアプリケーションを開発する方法が、以下の2つの動画で紹介されています。

fully immersive(VR)アプリケーションの開発:
https://developer.apple.com/videos/play/wwdc2023/10093/
immersive(MR)アプリケーションの開発:
https://developer.apple.com/videos/play/wwdc2023/10088

動画の中で、ハンドトラッキングの利用方法として以下の3通りの方法が提示されています。

  1. XR Interaction Toolkitを用いて既存のコンポーネントを使用
  2. Unity Input Systemを用いてビルトインのジェスチャを利用
  3. Unity Hands Packageを用いて間接位置のローデータを利用

上のものほどコンポーネント等が提供されているため簡単にハンドインプットが実装でき、逆に下に行くほど実装の難易度は上がりますがローデータを直接利用して柔軟にハンドインプットの実装ができるとのことです。

それぞれに関してサンプルプロジェクト等の調査を行いました。

1. XR Interaction Toolkit

使用ケース:既存コンポーネントを用いて簡単にTap・Drag・Grab・Pinchなどのインタラクションを実装したい場合

XRI サンプルシーン

提供されているコンポーネントを使用することでTap・Drag・Grab・Pinchなどのインタラクションは簡単に実装することができます。
しかしカスタムジェスチャを実装する機能に関しては、既存のコンポーネントは残念ながら見当たりませんでした。

2. Unity Input System

使用ケース:??
こちらはXRデバイス向けのサンプルプロジェクトを見つけることができず、また調査が難航しそうであったため未調査です。
Unityが提供しているInput Systemであり、XRに限らず様々なデバイスでの入力を処理するシステムとなっています。その中のInputSystem.XRXRControllerを用いることでXRデバイスを用いた際の入力処理を実装できるものであると想定されます。

https://docs.unity3d.com/Packages/com.unity.inputsystem@1.7/api/UnityEngine.InputSystem.XR.XRController.html

3. Unity Hands Package:XR Hands -HandVisualizer-

使用ケース:ローデータを用いて柔軟に実装を行いたい場合

XR Hands HandVisualizerサンプルシーン

間接位置や方向は取れているので、これらのデータを用いてコードベースでハンドジェスチャ認識を実装できそうです。


Xcodeのみを用いる場合

🚨こちらは実際に自身の手で確認することがまだできないため、現時点(2023/09/04)で公開されている動画やデモプロジェクトのコードを踏まえてのお話となります。

Xcodeでの実装に関しては、公式の動画で詳しく説明がされています。(下の動画 15:12~)
https://developer.apple.com/videos/play/wwdc2023/10082/
ハンドジェスチャ周りはARKitによって実現されているのですが、既に提供されている既存のジェスチャ判定と、関節情報を用いてカスタムジェスチャ判定を実装する方法のそれぞれに関してお話しします。

既存のジェスチャ判定

公式ドキュメントはこちらです。
https://developer.apple.com/documentation/swiftui/gesture

ドキュメントの中でも、SwiftUIですでに用意されているメソッドとして、特に主要なものは以下の3つであると見受けました。

- func onTapGesture()
- func onLongPressGesture()
- func gesture<T>()

上二つは名前の通りのメソッドで、タップやロングプレスを検知した際に実行するアクションを実装できます。

一番下のメソッドに関して、もう少し詳細に説明します。このメソッドを用いることで単純なタップやロングプレス以外のジェスチャの実装を行うことができます。

 func gesture<T>(_ gesture: Gesture, including mask: GestureMask)

Gestureとはprotocolであり、クラスや構造体が保持するプロパティやメソッドを定義する(C#でいうインタフェースのような)ものです。
引数として取ることができるGestureとして、以下の種類のstruct(構造体: クラスのようなもの)が用意されています。

既存のGestureとそれぞれによって実現できること
  • AnyGesture(): タイプ指定のなしでジェスチャを検知
  • TapGesture(): タップジェスチャを検知
  • DragGesture(): ドラッグジェスチャを検知
  • RotateGesture(): 回転ジェスチャを検知し、回転の角度をトラッキング
  • MagnifyGesture(): 拡大・縮小ジェスチャを検知し、倍率をトラッキング
  • SequenceGesture(): 2つのジェスチャの連続実行を検知
  • ExclusiveGesture(): 2つのジェスチャの内、片方のみを検知
  • SimultaneousGesture(): 2つのジェスチャの同時実行を検知
  • RotateGesture3D(): 3D回転ジェスチャを検知し、回転の角度と軸をトラッキング
  • LongPressGesture(): 長押しジェスチャを検知
  • SpatialTapGesture(): タップジェスチャを検知し、空間座標を返す
  • SpatialEventGesture(): クリック・タッチ・ピンチなどの空間イベントを検知
  • RotationGesture() (Deprecated): RotateGesture()の旧版
  • MagnificationGesture() (Deprecated): MagnifyGesture()の旧版

他にも以下のような追加の制約を実装することができます。

Entityへのターゲット指定

RealityKitのEntityを用いる場合は、以下のようなEntityへのターゲット制約を実装することができます。

  • targetedToAnyEntity(): 何かしらのEntityにターゲットしている状態である
  • targetedToEntity(Entity): 特定のEntityにターゲットしている状態である
  • targetedToEntity(where query:): あるクエリの結果として得られるEntityにターゲットしている状態である
アクションを実行するタイミング指定

アクションを実行するタイミングは以下のように指定することができます。

  • updating(): ジェスチャが変わった時に実装、変更後のジェスチャ状態を返す
  • onChanged(): ジェスチャが変わった時に実行
  • onEnded(): ジェスチャ終了時に実行
複数ジェスチャの組み合わせ指定
  • simultaneously(): もう1つジェスチャーを組み合わせて、2つが同時に検知される新しいジェスチャを作成
  • sequenced(): もう1つジェスチャーを順序付けて組み合わせて、2つが順序通りに検知される新しいジェスチャを作成
  • exclusively(): もう1つジェスチャを組み合わせて、初めのジェスチャのみが検知されるように優先度を付与

これらを組み合わせた実装の一例として、以下のようにジェスチャ判定時のコードを書くことができます。

`.gesture(TapGesture().targetedToAnyEntity().onEnded{ A } )`

これは、「何かしらのEntityにターゲット」した状態で「タップジェスチャ」が「終了した時」にAの内容が実行されます。


カスタムジェスチャ判定

利用できる取得情報

動画内に、間接位置や方向が取れている映像が含まれています。

  • HandAnchor
    HandAnchorは手の位置を検出するもので、手首にポジションされています。このHandAnchorから各関節を検知し取得しています。(詳しくは後に参照するデモプロジェクトのコードを確認してください。)
    HandAnchorを元に取得できる情報は以下の通りです。
    • simd_float4x4 originFromAnchorTransform: 手首に関する位置・回転情報
      • XCode 15 Beta8 以前の名称は simd_float4x4 transform
    • HandSkeleton? handSkeleton手の関節に関する情報(取得できる関節情報を後述)
    • HandAnchor.Chirality chirality:左右のどちらの手であるかに言及する情報
      • enum HandAnchor.Chirality
    • Bool isTracked: ARKitによって手がトラッキングされているかどうか
    • String description: Hand Anchorに関するテキスト情報

https://developer.apple.com/documentation/arkit/handanchor

  • HandSkeleton.Joint
    取得できる関節は画像のようになっています。関節名HandSkeleton.JoinNameは現在画像のものから変わっており、正しくは画像下の公式サイトにまとまっています。

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

具体的には、関節名を用いて以下のようなコードで関節を取得できます。

//左手の親指先端位置を取得
//変数名 = HandAnchor.handSkeleton.joint(取得したい関節名HandSkeleton.JointName)
let leftHandThumbTipPosition = leftHandAnchor.handSkeleton.joint(.thumbKnuckle)


デモプロジェクト

Appleより、ハンドジェスチャを利用したデモプロジェクトHappy Beamが公開されています。
内容としては、ハートのジェスチャを検知するとその中心からハッピービームが発射され、そのビームを不機嫌な雲に当ててハッピーな雲にするというものです。

Appleが公開しているデモプロジェクト HappyBeamの動画

https://developer.apple.com/documentation/visionos/happybeam
上記の公式ドキュメントに記述されているプログラムから、ハンドジェスチャ認識・判定に関する箇所を抜粋しました。

ハンドジェスチャ認識・判定に関するコード
HeartGestureModel.swift
/// Computes a transform representing the heart gesture performed by the user.
///
/// - Returns:
///  * A right-handed transform for the heart gesture, where:
///     * The origin is in the center of the gesture
///     * The X axis is parallel to the vector from left thumb knuckle to right thumb knuckle
///     * The Y axis is parallel to the vector from right thumb tip to right index finger tip.
///  * `nil` if either of the hands isn't tracked or the user isn't performing a heart gesture
///  (the index fingers and thumbs of both hands need to touch).
func computeTransformOfUserPerformedHeartGesture() -> simd_float4x4? {
    // Get the latest hand anchors, return false if either of them isn't tracked.
    guard let leftHandAnchor = latestHandTracking.left,
            let rightHandAnchor = latestHandTracking.right,
            leftHandAnchor.isTracked, rightHandAnchor.isTracked else {
        return nil
    }
    
    // Get all required joints and check if they are tracked.
    guard
        let leftHandThumbKnuckle = leftHandAnchor.handSkeleton?.joint(.thumbKnuckle),
        let leftHandThumbTipPosition = leftHandAnchor.handSkeleton?.joint(.thumbTip),
        let leftHandIndexFingerTip = leftHandAnchor.handSkeleton?.joint(.indexFingerTip),
        let rightHandThumbKnuckle = rightHandAnchor.handSkeleton?.joint(.thumbKnuckle),
        let rightHandThumbTipPosition = rightHandAnchor.handSkeleton?.joint(.thumbTip),
        let rightHandIndexFingerTip = rightHandAnchor.handSkeleton?.joint(.indexFingerTip),
        leftHandIndexFingerTip.isTracked && leftHandThumbTipPosition.isTracked &&
        rightHandIndexFingerTip.isTracked && rightHandThumbTipPosition.isTracked &&
        leftHandThumbKnuckle.isTracked && rightHandThumbKnuckle.isTracked
    else {
        return nil
    }
    
    // Get the position of all joints in world coordinates.
    let originFromLeftHandThumbKnuckleTransform = matrix_multiply(
        leftHandAnchor.originFromAnchorTransform, leftHandThumbKnuckle.anchorFromJointTransform
    ).columns.3.xyz
    let originFromLeftHandThumbTipTransform = matrix_multiply(
        leftHandAnchor.originFromAnchorTransform, leftHandThumbTipPosition.anchorFromJointTransform
    ).columns.3.xyz
    let originFromLeftHandIndexFingerTipTransform = matrix_multiply(
        leftHandAnchor.originFromAnchorTransform, leftHandIndexFingerTip.anchorFromJointTransform
    ).columns.3.xyz
    let originFromRightHandThumbKnuckleTransform = matrix_multiply(
        rightHandAnchor.originFromAnchorTransform, rightHandThumbKnuckle.anchorFromJointTransform
    ).columns.3.xyz
    let originFromRightHandThumbTipTransform = matrix_multiply(
        rightHandAnchor.originFromAnchorTransform, rightHandThumbTipPosition.anchorFromJointTransform
    ).columns.3.xyz
    let originFromRightHandIndexFingerTipTransform = matrix_multiply(
        rightHandAnchor.originFromAnchorTransform, rightHandIndexFingerTip.anchorFromJointTransform
    ).columns.3.xyz
    
    let indexFingersDistance = distance(originFromLeftHandIndexFingerTipTransform, originFromRightHandIndexFingerTipTransform)
    let thumbsDistance = distance(originFromLeftHandThumbTipTransform, originFromRightHandThumbTipTransform)
    
    // Heart gesture detection is true when the distance between the index finger tips centers
    // and the distance between the thumb tip centers is each less than four centimeters.
    let isHeartShapeGesture = indexFingersDistance < 0.04 && thumbsDistance < 0.04
    if !isHeartShapeGesture {
        return nil
    }
    
    // Compute a position in the middle of the heart gesture.
    let halfway = (originFromRightHandIndexFingerTipTransform - originFromLeftHandThumbTipTransform) / 2
    let heartMidpoint = originFromRightHandIndexFingerTipTransform - halfway
    
    // Compute the vector from left thumb knuckle to right thumb knuckle and normalize (X axis).
    let xAxis = normalize(originFromRightHandThumbKnuckleTransform - originFromLeftHandThumbKnuckleTransform)
    
    // Compute the vector from right thumb tip to right index finger tip and normalize (Y axis).
    let yAxis = normalize(originFromRightHandIndexFingerTipTransform - originFromRightHandThumbTipTransform)
    
    let zAxis = normalize(cross(xAxis, yAxis))
    
    // Create the final transform for the heart gesture from the three axes and midpoint vector.
    let heartMidpointWorldTransform = simd_matrix(
        SIMD4(xAxis.x, xAxis.y, xAxis.z, 0),
        SIMD4(yAxis.x, yAxis.y, yAxis.z, 0),
        SIMD4(zAxis.x, zAxis.y, zAxis.z, 0),
        SIMD4(heartMidpoint.x, heartMidpoint.y, heartMidpoint.z, 1)
    )
    return heartMidpointWorldTransform
}

要約すると、流れとしては以下のようになっています。

  1. HandAnchorを取得
  2. HandAnchorを利用して、ジェスチャ判定に利用したい関節を〇〇HandAnchor.handSkeleton?.joint(間接名)で取得
  3. 各関節の位置を取得
  4. 位置から関節同士の距離を計算し、ハートジェスチャ判定を行う
  5. ハートジェスチャの中心位置を取得し、xyz軸やベクターを定める
  6. ハートジェスチャの最終的な位置を取得

そして他のスクリプトでジェスチャ位置を利用してビームを発射させています。

ジェスチャ判定

ジェスチャの判定条件に着目すると、以下のように記述されています。

ハンドジェスチャ判定をしている箇所
let indexFingersDistance = distance(leftHandIndexFingerTipWorldPosition, rightHandIndexFingerTipWorldPosition)
let thumbsDistance = distance(leftHandThumbTipWorldPosition, rightHandThumbTipWorldPosition)

// Heart gesture detection is true when the distance between the index finger tips centers
// and the distance between the thumb tip centers is each less than four centimeters.
let isHeartShapeGesture = indexFingersDistance < 0.04 && thumbsDistance < 0.04
if !isHeartShapeGesture {
    return nil
}

つまり、左右の人差し指同士の距離が4cm以下 かつ 左右の親指同士の距離が4cm以下であれば判定されます。
(ハートでなくとも丸をつくっても判定されてしまうのでは...?という疑問は持ちましたが、残念ながら真偽は実機で確認するまでわかりません🥲)

最後に

以上、visionOS向けアプリケーションのハンドジェスチャ機能実装に関するお話でした。
実装がうまくいっているかの確認が現状だとできないのが難点ではありますが、参照したデモプロジェクトを参考に自身でもハンドジェスチャ判定を利用したアプリケーションの実装はできそうだと感じました!
今後もvisionOS向けの開発ツール等の公開・アップデートは行われていくかと思いますので、そちらでのハンドジェスチャ含めたハンドトラッキングの実装・カスタム方法も引き続き注目していきます!

おまけ〜関連記事の紹介〜

カスタムジェスチャのUX面に関して、MESONでディレクターを勤められているtakaさんが記事をまとめているので合わせてご確認ください!
https://note.com/takataka_ar/n/n6ff15a7c8e5d
また、デモプロジェクトHappy Beamにあるような3Dオブジェクトを空間内に表示させる部分に関しては、Xcodeに加えてRealityComposerProを利用します。こちらに関してもMESONのエンジニアがRealityComposerProを試してみた際の話を記事にまとめられているので、ご覧ください!
https://zenn.dev/meson/articles/visionos-reality-compooser-pro

エンジニア絶賛募集中!

MESONではUnityエンジニアを絶賛募集中です! XRのプロジェクトに関わってみたい! 開発したい! という方はぜひご応募ください!

MESONのメンバーページからご応募いただくか、TwitterのDMなどでご連絡ください。

書いた人

おたりな

大田 莉奈(あだな:おたりな)
ゲームエンジニア志望の慶應義塾大学理工学部4年生。
ゲーム・XR開発に興味があり、MESONのエンジニアインターンに参加。

GitHub / Twitter / Portfolio

MESON Works

MESONの制作実績一覧もあります。ご興味ある方はぜひ見てみてください。

MESON Works

Discussion