HoloLensで手の位置を取得したりUIに活用するTips


HoloLensの空間UIを試行錯誤してわかったことについて、主に手の位置を利用する方法についてまとめたいと思います。

実際どの様なことをしたかというと、以下の様にお絵描きアプリに手の位置や動きを利用したUIを検討し、組み込んだりしていました。


なお、本日のメニューは以下の様になっております。
- 手の位置を取得する方法
- UnityPlayerで動作確認をする方法
- 複数の手を判別、処理する方法
- 手の認識範囲に関する知見
- その他Tips

手の位置を取得する方法

HoloLensで手の位置を取得する方法は以下の3通りあります。

  • HoloToolkit.Unity.InputModuleのISourcePositionHandlerを使う
  • HoloToolkit.Unity.InputModuleのISourcePositionHandler以外のHandlerを使う
  • UnityEngine.XR.WSA.Input.InteractionManageを使う

それぞれの実装方法は以下の通りです。

HoloToolkit.Unity.InputModuleのISourcePositionHandlerを使う

多分これが一番メジャーなやり方で、手の位置が変更されたことを通知してくれます。変更されたらというか絶えず通知されてきます。

以下の通り、インタフェースを実装してeventDataのGripPositionを参照すればOKです。
SceneにInputManagerを追加しておく必要がありますが、「Apply Mixed Reality Scene Setting」をしておけばやってくれるのであまり意識することもないかと。

using UnityEngine;
using HoloToolkit.Unity.InputModule;

public class InputHandler : MonoBehaviour, ISourcePositionHandler {

    public void OnPositionChanged(SourcePositionEventData eventData)
    {
        // PointerPositionとGripPositionがあるけど、手の位置はGripの方
        var pos = eventData.GripPosition;
        // DO SOMETHING
    }
}

HoloToolkit.Unity.InputModuleのISourcePositionHandler以外のHandlerを使う

ISourcePositionHandler以外のHandlerでも、そのイベントが発生した際の手の位置を取得することができます。

using UnityEngine;
using HoloToolkit.Unity.InputModule;

public class InputHandler : MonoBehaviour, IInputClickHandler {

    public void OnInputClicked(InputClickedEventData eventData)
    {
        Vector3 pos;

        if (eventData.InputSource.TryGetGripPosition(eventData.SourceId, out pos))
        {
            // DO SOMETHING
        }
    }
}

後はこれらのスクリプトをScene内のGameObjectに追加すればよいのですが、MRTKのREADME にある通りデフォルトでは入力イベントは現在フォーカスしているオブジェクトに対して通知されることになります。

つまり、スクリプトを追加したオブジェクトがフォーカスされている時以外は、イベントが通知されませんorz。これはエアタップ等の他のイベントについても同様です。

FallbackInputHandler, ModalInputHandlerを使おう

何かにフォーカスした状態じゃないと手の位置をとれないのは困りますし、空間をタップしたい場合も困ります。

そういった場合は、InputManagerのPushFallbackInputHandlerやPushModalInputHandlerを利用することで、フォーカスされていない状態でもイベントが通知されるようになります。

using UnityEngine;
using HoloToolkit.Unity.InputModule;

public class InputHandler : MonoBehaviour, ISourcePositionHandler {
    private void OnEnable()
    {
        // FallbackInputHandlerを登録
        InputManager.Instance.PushFallbackInputHandler(gameObject);
    }

    private void OnDisable()
    {
        // 不要になったら解放。アプリ終了時に例外が出る事があるのでnullチェック
        if (InputManager.Instance)
        {
            InputManager.Instance.PopFallbackInputHandler();
        }
    }

    public void OnPositionChanged(SourcePositionEventData eventData)
    {
        // フォーカスされていなくても呼ばれる!
        var pos = eventData.GripPosition;
        // DO SOMETHING
    }
}

FallbackInputHandlerとModalInputHandlerの違い

イベント発生時は以下のような順番でイベントが処理されます。

  1. ModalInputHandlerにイベントが通知される。複数登録されている場合、LIFOで最後に登録されたものが呼び出される。
  2. ModalInputHandlerで処理されなければ、フォーカスされたオブジェクトにイベントが通知される。
  3. フォーカスされたオブジェクトでも処理されなければ、FallbackInputHandlerにイベントが通知される。

なので、ModalInputHandlerはモーダルダイアログ的に使いたい場合、FallbackInputHandlerはオブジェクトのタップと空間をタップを共存させたい場合に使うとよいと思います。

基本的に空間タップしたい場合はFallbackInputHandlerでよいかと思います。

UnityEngine.XR.WSA.InputのInteractionManageを使う

こちらはMRTKではなく、UnityEngineのInteractionManagerのイベントハンドラにコールバック関数を登録する形になります。

using UnityEngine;
using UnityEngine.XR.WSA.Input;

public class InputHandler : MonoBehaviour {

    private void OnEnable()
    {
        InteractionManager.InteractionSourceUpdated += InteractionManager_InteractionSourceUpdated;
    }

    private void OnDisable()
    {
        InteractionManager.InteractionSourceUpdated -= InteractionManager_InteractionSourceUpdated;
    }

    private void InteractionManager_InteractionSourceUpdated(InteractionSourceUpdatedEventArgs obj)
    {
        Vector3 handPosition;
        if (obj.state.sourcePose.TryGetPosition(out handPosition))
        {
            // DO SOMETHING
        }
    }
}

こちらはフォーカスしているオブジェクトなど関係なしにイベントが通知されます。
そのため、オブジェクトに対するタップ操作などを判定するには、自前でレイを飛ばして判定する必要があります。

手の位置をとるだけであればよいですが、実際のところオブジェクトをタップするときのことや、今後のMRTKのアップデートなどを考えると、素直にMRTKのHandlerを使ったほうが良いかと思います。

UnityPlayerで動作確認

ところでHoloLensアプリをUnityPlayerで実行すると、下に手のマークが現れるのですが、使い方がわからずずっと実機確認をしていました。
最近やっとその使い方がわかり、UnityPlayer上で簡単な動作確認ができるようになったので紹介したいと思います。(みんな知ってたのかな、、、

  1. Shift, Spaceキーを押したままにする。
  2. マウスをドラッグする。
  3. マウスを左クリックする。

これでUnityPlayer上で手を動かして、Air-Tapをすることができます。

具体的な操作と発生するイベントの対応は以下の通りです。

  • Shift, Spaceキーのon/off

ISourceStateHandlerのOnSourceDeteted, OnSourceLostが呼び出される

  • キーonの状態でマウスクリック

IInputClickHandlerのOnInputClickedが呼び出される

  • キーonの状態でマウスドラッグ

IManipulationHandlerのOnManipulationStarted, Updated, Canceled, Completedが呼び出される

  • キーonの状態でマウスの移動

ISourcePositionHandlerのOnPositionChangedは呼び出されない(MRTKのバグ?仕様?

複数の手を処理する方法

各種イベントハンドラが呼び出された際、イベントの発生源のIDがEventDataに含まれる(eventData.SourceId)ので、そちらのIDを使って、複数の手を判別することができます。

手の操作状態や手毎に管理しないといけない情報はクラスにまとめて、Dictionaryで管理するとよいと思います。

    private Dictionary<uint, HandState> handStateMap;
    private class HandState
    {
        public Vector3 Position { get; set; }
        //略
    }

    private HandState GetHandState(uint sourceId)
    {
        if (handStateMap.ContainsKey(sourceId))
        {
            return handStateMap[sourceId];
        }
        else
        {
            var handState = new HandState(...);
            handStateMap.Add(sourceId, handState);
            return handState;
        }
    }

    public void OnPositionChanged(SourcePositionEventData eventData)
    {
        var handState = GetHandState(eventData.SourceId);
        handState.Position = eventData.GripPosition;
        // HandStateの情報を参照したり、更新したりして処理をする
    }

    public void OnSourceLost(SourceStateEventData eventData)
    {
        // 手の認識ロスト時に解放する。
        // 再度同じ手を認識しても同じIDが降られるとは限らないので、
        // 手の情報は認識時~ロスト時までのライフサイクルとしておく。
        if (handStateMap.ContainsKey(eventData.SourceId))
        {
            handStateMap.Remove(eventData.SourceId);
        }
    }

手の認識範囲について

手の認識範囲はHoloLensの描画範囲と比べてかなり広いです。以下のような風景を見ている際に、HoloLensの描画範囲を青枠とすると、手の認識範囲は赤枠位の範囲となります。

そのため、エアタップをする際は、手を顔の前まで上げずに胸や腹の前くらいでも普通に反応します。

また、冒頭で紹介したお絵描きアプリでは、この低い位置の手の左右移動をペンの色選択に利用していたりします。そのほかにも手の上下移動でメニューの選択を移動させたり。

その他Tips

そういったものを作ろうとした場合、単純に手の位置からどうやって上下左右の判定をしているのかということを考える必要がありますね。

自分から見た手の位置をとりたい

Transform::InverseTransformPointで引数で指定したワールド座標をtransformのローカル座標に変換できるので、以下の様にすれば自分から見た相対座標が取得できます。

var localHand = Camera.main.transform.InverseTransformPoint(pos);

localHandのxが右左、yが上下、zが前後を表します。
同じようにワールド座標のベクトルをローカル座標のベクトルに変換したい場合は、Transform::InverseTransformPointではなくTransform::InverseTransformVectorで変換できます。

視線に対する手の位置の角度をとりたい

localHandのx,y,zから逆三角関数(atan)を使って角度が求まります。

var horizontalRadius = Mathf.Atan(localHand.x / localHand.z);
var verticalRadius = Mathf.Atan(localHand.y / localHand.z);

直交座標系で考えるより、視点を原点とした極座標系で考えた方が、主観的に手がどれくらい右か、どれくらい上かというのが判断しやすいんじゃなかろうかなーと。まぁ、ここら辺はまだまだ要研究ですかね。

まとめ

色々詰め込みすぎてやたら長かったですが、イベントハンドラーの基本的な使い方+αくらいで、個々の内容については結構簡単な感じかなーと。

ただ、空間UIとしては意外と簡単な処理の組み合わせで面白いことができるので、これを機に興味を持ってもらえるととても嬉しいです。MRの空間UIの新たな可能性を切り拓こうぜ!