👐️

Unity PolySpatialを使ってハンドトラッキングする

2024/02/19に公開

キービジュアル

概要

MESONではApple Vision Pro向けの開発を積極的に行っています!

今月はApple Vision Proの発売記念ということで1ヶ月記事チャレンジを行っています。この記事はその2/19の記事です。

2/18の記事はこちら(純粋くんと西田はかせ ⋆ 第一話 ⋆)

ちなみにアプリ開発も行っていて、第一弾として「SunnyTune」という天気を体感できるアプリを開発し、Apple Vision Proのローンチに合わせてリリースしています!

SunnyTune

https://prtimes.jp/main/html/rd/p/000000055.000032228.html

空間コンピューティング時代における新しい体験作りを今後もしていきたいと思っています!


自分は前回、Unity PolySpatialを使ってApple Vision Pro向けアプリを開発するという記事を書きました。今回はPolySpatialを用いてハンドトラッキングするためのあれこれを書いていきたいと思います。

https://create.unity.com/spatial

開発環境

前回同様、PolySpatialで開発を行うのに必要な環境は以下の通りです。

  • Apple sillicon Mac(Intel Macは非対応)
  • Xcode 15
  • Unity 2022.3.18以上(URP推奨)
    • Unityライセンス(Pro / Enterprise / Industry)必須
  • visionOS Build Support
  • com.unity.polyspatial.xr
  • com.unity.polyspatial.visionos

uGUIを利用する

uGUIについては 1.0.3 時点では特に特別な対応をしなくてもインタラクションが行えるようになっているようです。ただし、PolySpatialの制約でCanvasをWorld Spaceに配置する必要がある点に注意です。

以下のように、 Button コンポーネントの OnClick にコンポーネントをアサインしておくと正常に呼び出されます。(Script経由でイベントを設定しても問題ありません)

ボタンのイベント設定

using UnityEngine;

public class ClickTest : MonoBehaviour
{
    public void Test()
    {
        Debug.Log("ボタンがクリックされた! - " + name);
    }
}

視線がオブジェクトに向いているかを検知する - VisionOSHoverEffect コンポーネント

visionOSでは視線トラッキングを利用して、UIに視線が向くとリアクションするようになっています。ただ、視線情報はプライバシーに該当するため、今どこを見ているかという詳細な情報は取れず、オブジェクトを見ているかどうかのみ判定することができます。

このエフェクトを得るためのコンポーネントがPolySpatialには用意されています。視線に反応させたいオブジェクトに VisionOSHoverEffect コンポーネントを付与すると、視線がオブジェクトに触れた際にふんわりと光るエフェクトが追加されます。

ちょっと分かりづらいですが、見ているオブジェクトがふんわり光るのが分かるかと思います。

VisionOSHoverEffect

シンプルにタップ操作を得る

Unityの標準機能で言うところの Input.touches 的な役割の紹介です。タップしたという事実だけを扱いたい場合などに利用できます。

using Unity.PolySpatial;
using UnityEngine;
using UnityEngine.InputSystem.EnhancedTouch;
using UnityEngine.InputSystem.LowLevel;
using Unity.PolySpatial.InputDevices;
using UnityEngine.InputSystem.Utilities;
using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch;

public class TouchTest : MonoBehaviour
{
    private void OnEnable()
    {
        EnhancedTouchSupport.Enable();
    }

    private void Update()
    {
        ReadOnlyArray<Touch> activeTouches = Touch.activeTouches;

        if (activeTouches.Count == 0) return;
        
        SpatialPointerState primaryTouchData = EnhancedSpatialPointerSupport.GetPointerState(activeTouches[0]);

        if (primaryTouchData.phase == SpatialPointerPhase.Began)
        {
            // Do something that you want.
        }
    }
}

まず、本機能を利用するためにどこかのスクリプトで以下を実行する必要があります。これはstaticメソッドになっているので実行するだけで大丈夫です。

UnityEngine.InputSystem.EnhancedTouch.EnhancedTouchSupport.Enable()

次に、旧Input Systemの Iput.GetKeyDown() と同様に Update メソッド内で状況を監視します。

private void Update()
{
    ReadOnlyArray<Touch> activeTouches = Touch.activeTouches;

    if (activeTouches.Count == 0) return;

    SpatialPointerState primaryTouchData = EnhancedSpatialPointerSupport.GetPointerState(activeTouches[0]);

    if (primaryTouchData.phase == SpatialPointerPhase.Began)
    {
        // Do something that you want.
    }
}

もしユーザがタップ動作をしている場合は Touch.activeTouches の配列数が 1 以上になっているのでそれによって処理を分岐することができます。

以下の処理で対象のタッチアクションに紐づく情報を取得することができます。

SpatialPointerState primaryTouchData = EnhancedSpatialPointerSupport.GetPointerState(activeTouches[0]);

SpatialPointerStatephase プロパティをチェックして開始時であれば処理をする、とすることでタップ時に処理を実行することができるようになります。

タップした対象オブジェクトの情報を得る

タップした際に見ていたオブジェクトがある場合は、その情報を取得することもできます。以下は、上記コードに追記したものです。

private void Update()
{
    ReadOnlyArray<Touch> activeTouches = Touch.activeTouches;

    if (activeTouches.Count == 0) return;
    
    SpatialPointerState primaryTouchData = EnhancedSpatialPointerSupport.GetPointerState(activeTouches[0]);

    // 中略

    SpatialPointerKind interactionKind = primaryTouchData.Kind;
    GameObject objectBeingInteractedWith = primaryTouchData.targetObject;
    Vector3 interactionPosition = primaryTouchData.interactionPosition;

    Debug.Log($">>>> {interactionPosition} with [{objectBeingInteractedWith.name}]");
    Debug.Log($">>>> {objectBeingInteractedWith.GetComponent<VisionOSHoverEffect>()}");
}

SpatialPointerState オブジェクトから、見ていた対象のオブジェクト情報やインタラクションした位置情報などが取得できるので、これを用いて見ていた位置にオブジェクトを生成、などのような処理が可能となります。

ハンドトラッキングデータを用いて処理を行う

最後に紹介するのはハンドトラッキングを扱う方法です。これを利用すると指の位置(関節位置)などが取得できるため、例えばピンチ状態になったらオブジェクトを生成する、などのような制御が可能となります。

以下は、PolySpatialのサンプルコードに含まれていたものを少し改変したバージョンのコードです。人差し指と親指の距離を見て、近かったら(ピンチ状態だったら)オブジェクトを生成する、というコードです。

全体だと長いので抜粋しながら解説します。コード全文は後半に掲載します。

ハンドサブシステムを取得する

まず最初に、XR Plug-in Managementで管理されている XRHandSubsystem を取得します。
取得するには XRGeneralSettings から紐づく設定データを利用します。

private void GetHandSystem()
{
    XRGeneralSettings xrGeneralSettings = XRGeneralSettings.Instance;
    if (xrGeneralSettings == null)
    {
        Debug.LogError("XR general settings not set.");
        return;
    }

    XRManagerSettings manager = xrGeneralSettings.Manager;
    if (manager == null)
    {
        Debug.LogError("XR Manager Settings not set.");
        return;
    }

    XRLoader loader = manager.activeLoader;
    if (loader == null)
    {
        Debug.LogError("XR Loader not set.");
        return;
    }

    _handSubsystem = loader.GetLoadedSubsystem<XRHandSubsystem>();
    if (!CheckHandSubsystem())
    {
        return;
    }

    _handSubsystem.Start();
}

無事に取得できたら Start メソッドを呼んでサブシステムを開始します。

ちなみに以前、XR Plug-in Managementの挙動が気になって調べたことがあるので、興味がある人は読んでみてください。
https://edom18.hateblo.jp/entry/2022/09/24/xr-plugin-management

手の状態を監視する

無事に XRHandSubsystem が起動できたら、それを用いて手の状態を監視します。こちらも Update メソッド内で監視しています。

処理の概要は、

  1. TryUpdateHands で手の状態フラグを取得
  2. フラグに応じて処理を分岐(どちらの手のどんな状態か)
  3. サブシステムから該当のジョイント情報を得る
  4. 得られた情報をもとに処理する

これらを実行しているコードは以下の通りです。

private void Update()
{
    if (!CheckHandSubsystem()) return;

    XRHandSubsystem.UpdateSuccessFlags updateSuccessFlags = _handSubsystem.TryUpdateHands(XRHandSubsystem.UpdateType.Dynamic);
    
    if ((updateSuccessFlags & XRHandSubsystem.UpdateSuccessFlags.RightHandRootPose) != 0)
    {
        // assign joint values
        _rightIndexTipJoint = _handSubsystem.rightHand.GetJoint(XRHandJointID.IndexTip);
        _rightThumbTipJoint = _handSubsystem.rightHand.GetJoint(XRHandJointID.ThumbTip);
        
        DetectPinch(_rightIndexTipJoint, _rightThumbTipJoint, ref _activeRightPinch, true);
    }

    if ((updateSuccessFlags & XRHandSubsystem.UpdateSuccessFlags.LeftHandRootPose) != 0)
    {
        // assign joint values.
        _leftIndexTipJoint = _handSubsystem.leftHand.GetJoint(XRHandJointID.IndexTip);
        _leftThumbTipJoint = _handSubsystem.leftHand.GetJoint(XRHandJointID.ThumbTip);
        
        DetectPinch(_leftIndexTipJoint, _leftThumbTipJoint, ref _activeLeftPinch, false);
    }
}

得られたジョイント情報をもとに、指の近さを判定します。

private void DetectPinch(XRHandJoint index, XRHandJoint thumb, ref bool activeFlag, bool right)
{
    GameObject spawnObject = right ? _rightSpawnPrefab : _leftSpawnPrefab;

    if (index.trackingState == XRHandJointTrackingState.None ||
        thumb.trackingState == XRHandJointTrackingState.None)
    {
        Debug.LogWarning("Index or thumb tracking state is None.");
        return;
    }

    Vector3 indexPosition = Vector3.zero;
    Vector3 thumbPosition = Vector3.zero;

    if (index.TryGetPose(out Pose indexPose))
    {
        indexPosition = indexPose.position;
    }

    if (thumb.TryGetPose(out Pose thumbPose))
    {
        thumbPosition = thumbPose.position;
    }

    float pinchDistance = Vector3.Distance(indexPosition, thumbPosition);
    if (pinchDistance <= _scaledThreshold)
    {
        if (!activeFlag)
        {
            // エフェクトを再生
            GameObject go = Instantiate(spawnObject, indexPosition, Quaternion.identity);
            go.transform.localScale = Vector3.one / 5f;
            activeFlag = true;
        }
    }
    else
    {
        activeFlag = false;
    }
}

最後にコード全文を載せておきます。

コード全文
using UnityEngine;
using UnityEngine.XR.Hands;
using UnityEngine.XR.Management;

public class HandTrackingTest : MonoBehaviour
{
    [SerializeField] private Transform _polySpatialCameraTransform;
    [SerializeField] private GameObject _rightSpawnPrefab;
    [SerializeField] private GameObject _leftSpawnPrefab;

    private XRHandSubsystem _handSubsystem;
    private XRHandJoint _rightIndexTipJoint;
    private XRHandJoint _rightThumbTipJoint;
    private XRHandJoint _leftIndexTipJoint;
    private XRHandJoint _leftThumbTipJoint;

    private bool _activeRightPinch;
    private bool _activeLeftPinch;
    private float _scaledThreshold;

    private const float k_PinchThreshold = 0.02f;

    private void Start()
    {
        GetHandSystem();
        _scaledThreshold = k_PinchThreshold / _polySpatialCameraTransform.localScale.x;
    }

    private void Update()
    {
        if (!CheckHandSubsystem()) return;

        XRHandSubsystem.UpdateSuccessFlags updateSuccessFlags = _handSubsystem.TryUpdateHands(XRHandSubsystem.UpdateType.Dynamic);
        
        if ((updateSuccessFlags & XRHandSubsystem.UpdateSuccessFlags.RightHandRootPose) != 0)
        {
            // assign joint values
            _rightIndexTipJoint = _handSubsystem.rightHand.GetJoint(XRHandJointID.IndexTip);
            _rightThumbTipJoint = _handSubsystem.rightHand.GetJoint(XRHandJointID.ThumbTip);
            
            DetectPinch(_rightIndexTipJoint, _rightThumbTipJoint, ref _activeRightPinch, true);
        }

        if ((updateSuccessFlags & XRHandSubsystem.UpdateSuccessFlags.LeftHandRootPose) != 0)
        {
            // assign joint values.
            _leftIndexTipJoint = _handSubsystem.leftHand.GetJoint(XRHandJointID.IndexTip);
            _leftThumbTipJoint = _handSubsystem.leftHand.GetJoint(XRHandJointID.ThumbTip);
            
            DetectPinch(_leftIndexTipJoint, _leftThumbTipJoint, ref _activeLeftPinch, false);
        }
    }

    private void GetHandSystem()
    {
        XRGeneralSettings xrGeneralSettings = XRGeneralSettings.Instance;
        if (xrGeneralSettings == null)
        {
            Debug.LogError("XR general settings not set.");
            return;
        }

        XRManagerSettings manager = xrGeneralSettings.Manager;
        if (manager == null)
        {
            Debug.LogError("XR Manager Settings not set.");
            return;
        }

        XRLoader loader = manager.activeLoader;
        if (loader == null)
        {
            Debug.LogError("XR Loader not set.");
            return;
        }

        _handSubsystem = loader.GetLoadedSubsystem<XRHandSubsystem>();
        if (!CheckHandSubsystem())
        {
            return;
        }

        _handSubsystem.Start();
    }

    private bool CheckHandSubsystem()
    {
        if (_handSubsystem == null)
        {
#if !UNITY_EDITOR
            Debug.LogError("Could not find Hand Subsystem.");
#endif
            enabled = false;
            return false;
        }

        return true;
    }

    private void DetectPinch(XRHandJoint index, XRHandJoint thumb, ref bool activeFlag, bool right)
    {
        GameObject spawnObject = right ? _rightSpawnPrefab : _leftSpawnPrefab;

        if (index.trackingState == XRHandJointTrackingState.None ||
            thumb.trackingState == XRHandJointTrackingState.None)
        {
            Debug.LogWarning("Index or thumb tracking state is None.");
            return;
        }

        Vector3 indexPosition = Vector3.zero;
        Vector3 thumbPosition = Vector3.zero;

        if (index.TryGetPose(out Pose indexPose))
        {
            indexPosition = indexPose.position;
        }

        if (thumb.TryGetPose(out Pose thumbPose))
        {
            thumbPosition = thumbPose.position;
        }

        float pinchDistance = Vector3.Distance(indexPosition, thumbPosition);
        if (pinchDistance <= _scaledThreshold)
        {
            if (!activeFlag)
            {
                // エフェクトを再生
                GameObject go = Instantiate(spawnObject, indexPosition, Quaternion.identity);
                go.transform.localScale = Vector3.one / 5f;
                activeFlag = true;
            }
        }
        else
        {
            activeFlag = false;
        }
    }
}

まとめ

手の操作を扱った処理はこれ以外にもあると思いますが、既存のプロジェクトをざっと移植するには十分な情報かと思います。マウスのようなポインティングについては視線で自動的にやってくれるため、既存のUIなどの処理はタップなどの処理を判定して行うことで移植がスムーズに行くでしょう。

MESONからのお知らせ

エンジニア絶賛募集中!

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

https://careers.meson.tokyo/

体験会・勉強会実施のお知らせ

MESONでは、オフィスなどでのVision Pro体験会実施や企業向けの研修プログラムの提供を行っております。今回紹介したアプリもインストールされてますので、興味のある方はぜひお問い合わせフォームよりご連絡お待ちしております。

https://meson.typeform.com/to/q4omFL

企業のご担当者の方

Apple Vision Proのエントリー勉強会プログラム「Ready for Vision Pro」を提供開始しております。興味のある方は是非お気軽にお問い合わせ下さい。

https://prtimes.jp/main/html/rd/p/000000056.000032228.html

書いた人

えど

比留間 和也(あだな:えど)

カヤック時代にWEBエンジニアとしてリーダーを務め、その後VRに出会いコロプラに転職。 コロプラでは仮想現実チームにてXRコンテンツ開発に携わる。 DAYDREAM向けゲーム「NYORO THE SNAKE & SEVEN ISLANDS」をリリース。その後、ARに惹かれてMESONに入社。 MESONではARエンジニアとして活躍中。
またプライベートでもAR/VRの開発をしており、インディー部門でTGSに出展など公私関わらずAR/VRコンテンツ制作に精を出す。プライベートな時間でも開発しているように、新しいことを学ぶことが趣味で、最近は英語を学んでいる。

GitHub / Twitter

MESON Works

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

MESON Works

Discussion