MixedRealityToolkit-UnityのInputManagerを紐解く ~Gaze編~

投稿者: | 2017-09-10

今年の夏にHPやAcerからWindows Mixed Reality Headsetがリリースされました。また、今冬にはモーションコントローラのリリースも予定されています。開発者としては開発方法がどのように変化するのかが気になるところですが、HoloToolkitがMixedRealityToolkitと名前が変更されたことからこれまでと同じ感触で開発ができそうな雰囲気を感じています。とはいえ入力系は多少の変更が入ると思われますので、変更が入る前に一度現在のバージョンの内容を確認したいと思います。個人的に過去の仕様を把握しておけば新しいものが出たとしてもスムーズに移行でき、かつ不測の事態にも対応しやすいので大切だと思っています。(超破壊的変更であった場合はあまり効果はないかもしれませんが)

Gaze、ジェスチャー、音声を一つの記事にしようと思ったのですが、なかなかの量になりそうなので分轄して投稿します。

HoloLensアプリケーションにおける入力の3要素

キーボードやマウスといった周辺機器を使わずに、HoloLens単体でデバイスを操作するときにはGaze(視線)、ジェスチャー、音声が入力の主要な要素となります。

Gaze

画面中央に表示されたカーソルを、頭の動きによって移動させて何かを選択する操作です。アイトラッキングを行っているわけではないので、Gaze(視線)という呼び方には違和感があるかもしれませんが、ここでも公式通りにGazeとしたいと思います。

PCマウスにおける、マウスカーソルの操作にあたるのがGazeです。

ジェスチャー

HoloLensにはデプスセンサー、IRカメラが搭載されており、それらを利用してハンドジェスチャーでデバイスに命令する操作です。

PCマウスにおける、クリックやドラッグに相当します。

音声

デバイスに搭載されたマイクと音声認識エンジンを利用し、ボイスコマンドでデバイスに命令する操作です。

Gazeのしくみ

GazeはHoloToolkit-UnityのInputManagerをシーンに配置するだけで使えるようになります。しかし、それだけだと視覚的には何が起こっているのかわかりませんので、DefaultCursorやBasicCusorも一緒に配置してカーソルを表示する必要があります。

Gazeの制御を行っているのはInputManagerにアタッチされているGazeManagerです。このスクリプトにてメインカメラ(Cameraオブジェクト)とRayを使うことにより、Gazeを実現しています。

何度か本ブログでも説明していますが、Unity製HoloLensアプリにおけるワールド座標の原点は、アプリ起動時のHoloLensの位置となります。アプリ起動後はHoloLensのセルフトラッキングによって常にHoloLensの位置座標をUnityのメインカメラ(Camera.main)から取得することができます。またHoloLensにはIMUも搭載されておりますので姿勢を取得することもでき、位置と同様にメインカメラから姿勢情報を取得することができます。

GazeManagerでは毎フレーム、メインカメラの位置から前方方向(transform.forward)にRay(以下、このRyaを視線Rayと呼ぶ)を飛ばして、衝突したオブジェクトを「選択しているオブジェクト(フォーカスを当てている)」として扱います。GazeManagerからはフォーカスしているオブジェクトについての情報をはじめとして、様々な情報をプロパティ経由で取得することができます。(プロパティ一覧は後述)

なお、GazeStabilizerはカーソルの動き(正確には視線Rayの発射地点)を制御するスクリプトで、頭の動きに対してカーソルが滑らかに動くよう、補間値を計算しています。

インスペクタ上での設定項目

Max Gaze Collision Distance
視線Rayの長さを設定できます。遠くに置いたオブジェクトをGazeしたい場合は、この値を大きくする必要があります。
Raycast Layer Masks
視線Rayとの衝突を判定する際に使用するレイヤーマスクを指定します。複数のレイヤーマスクを指定した場合は、視線RayによるRaycast結果を先頭のレイヤーマスクからフィルタリングし、一番最初に該当したものにフォーカスが当たります。(使用機会はよくわからない…)
Stabilizer
視線Rayの発射地点を制御するスクリプトを指定します。prefabにはGazeStabilizerが指定されており、こちらは視線Rayの発射地点をメインカメラの位置へ補間して移動することにより、カーソルがBody-Lockの動きをするよう制御します。何も指定しない場合はカーソルは画面中央にDisplay-Lockされます。
Gaze Transform
視線Rayの発射地点と移動方向を決めるTransformを指定します。何も指定しない場合はメインカメラ(Camera.main)がセットされます。

プロパティとイベント

GazeManagerのプロパティからはフォーカスを当てているオブジェクトに関する情報を取得できます。

public bool IsGazingAtObject { get; private set; }
説明 何かしらのオブジェクトにフォーカスを当てているかどうか
public RaycastHit HitInfo { get { return hitInfo; }
説明 フォーカスを当てているオブジェクトについてのRaycastHit
public GameObject HitObject { get; private set; }
説明 フォーカスを当てているGameObject
public Vector3 HitPosition { get; private set; }
説明 オブジェクトにフォーカスが当たっているときは視線Rayとオブジェクトが衝突した地点。オブジェクトにフォーカスが当たっていないときはフォーカスを当てていたオブジェクトとの距離だけ視線Ray方向に進んだ地点が返ってくる。
public Vector3 GazeOrigin { get; private set; }
説明 視線Rayの発射地点
public Vector3 GazeNormal { get; private set; }
説明 視線Rayの進行方向(単位ベクトル)

また、フォーカス対象が変化したときはGazeManagerからイベントが発火します。

FocusedObjectChanged
デリゲート void FocusedChangedDelegate(GameObject previousObject, GameObject newObject);
説明 フォーカスが当たったとき、もしくは外れたときに発火する。newObjectには新しくフォーカスが当たったオブジェクトがセットされる(当たっていない場合はnull)。previousObjectには前フレームでフォーカスが当たっていたオブジェクトがセットされる(当たっていない場合はnull)。

実装

前述した通り、GazeManagerでは毎フレームRayを飛ばして、オブジェクトにフォーカスが当たっているかをチェックしています。そこでまずはUpdateと起動時の処理がどのようになっているのかを見てみます。

public class GazeManager : Singleton<GazeManager>
{
    protected override void Awake()
    {
        base.Awake();
        // LayerMaskが指定されていない場合はUnity標準に従う
        if (RaycastLayerMasks == null || RaycastLayerMasks.Length == 0)
        {
            RaycastLayerMasks = new LayerMask[] { Physics.DefaultRaycastLayers };
        }
        // 視線Rayの発射地点となるGameObjectの確認
        FindGazeTransform();
    }

    /// 視線Rayの発射地点となるTransformの確認
    private bool FindGazeTransform()
    {
        // 指定されていたらOK
        if (GazeTransform != null) { return true; }
        // メインカメラが存在するならば、それをセット
        if (Camera.main != null)
        {
            GazeTransform = Camera.main.transform;
            return true;
        }
        // 設定がおかしい
        Debug.LogError("Gaze Manager was not given a GazeTransform and no main camera exists to default to.");
        return false;
    }

    private void Update()
    {
        // 視線Rayを発射するオブジェクトが設定されているかを確認
        if (!FindGazeTransform())
        {
            return;
        }
        // 視線Rayの発射地点を更新する
        UpdateGazeInfo();
        // 3Dオブジェクトに対してRaycastを行いフォーカス対象を見極める
        // フォーカス対象はメソッドの中で設定され、返り値として前フレームのフォーカス対象を取得する
        GameObject previousFocusObject = RaycastPhysics();
        // EventSystemがシーン上に存在すれば、uGUIに対してもRayを飛ばす
        if (EventSystem.current != null)
        {
            // uGUIに対してもRaycastを飛ばしてフォーカスが当たるかどうか判定する
            // すでに3Dオブジェクトに対してフォーカスが当たっていれば
            // 距離等を考慮して3DオブジェクトとuGUIのどちらにフォーカスを当てるか決定する
            RaycastUnityUI();
        }
        // フォーカス対象が変更されているならば、イベントを発火する
        if (previousFocusObject != HitObject && FocusedObjectChanged != null)
        {
            FocusedObjectChanged(previousFocusObject, HitObject);
        }
    }
}

Updateの流れは次の通りです。

  1. 視線Rayの発射元であるメインカメラ、すなわちHoloLensが、どこに位置していてどこを向いているかを確認する。
  2. 3Dオブジェクトに対するRayを飛ばして、オブジェクトと衝突するかを確認する。
  3. uGUIに対するRayを飛ばして衝突するかを確認する。
  4. 結果として3DオブジェクトとuGUIの両方に衝突した場合は、どちらにフォーカスを当てるか決定する。
  5. 前フレームと比較して、フォーカスの状態が変化していたらイベントを発火する。

それでは、各要素について詳細を見ていきます。

現時点でのHoloLensの位置と姿勢を確認する

前述の通り、HoloLensの位置はメインカメラから取得できますので、メインカメラのtransformからpositionとforwardを取得します。

/// 視線Rayの発射地点を更新する
private void UpdateGazeInfo()
{
    // メインカメラの現在地点と前方方向(+Z軸)を取得する
    Vector3 newGazeOrigin = GazeTransform.position;
    Vector3 newGazeNormal = GazeTransform.forward;
    // スタビライザーがセットされている場合は補間位置を取得
    if (Stabilizer != null)
    {
        Stabilizer.UpdateStability(newGazeOrigin, GazeTransform.rotation);
        newGazeOrigin = Stabilizer.StablePosition;
        newGazeNormal = Stabilizer.StableRay.direction;
    }
    // 最終的な発射位置
    GazeOrigin = newGazeOrigin;
    GazeNormal = newGazeNormal;
}

なお、デフォルトではスタビライザーが有効化されていますので、正確にはメインカメラの前フレームと現フレームの位置の補間地点が発射位置となります。(Rayの進行方向についても同様)

3Dオブジェクトに対してRayを飛ばす

Physics.Raycastを使って視線Rayを飛ばします。

private GameObject RaycastPhysics()
{
    // 前フレームでフォーカスを当てていたGameObjectを取得
    GameObject previousFocusObject = HitObject;
    // 指定されているLayerMaskが単一の場合
    if (RaycastLayerMasks.Length == 1)
    {
        // 視線Rayをキャストする
        // ヒットしたかどうかが返ってくるので、プロパティにセットする
        IsGazingAtObject = Physics.Raycast(GazeOrigin, GazeNormal, out hitInfo, MaxGazeCollisionDistance, RaycastLayerMasks[0]);
    }
    else
    {
        // とりあえずLayerを無視してRayをキャストし、キャスト結果とLayerMask配列を使い優先度を考慮して
        // フォーカス対象を選定する
        RaycastHit? hit = PrioritizeHits(Physics.RaycastAll(new Ray(GazeOrigin, GazeNormal), MaxGazeCollisionDistance, -1));
        IsGazingAtObject = hit.HasValue;
        if (IsGazingAtObject)
        {
            hitInfo = hit.Value;
        }
    }
    // ヒットした場合(フォーカスが当たっている場合)
    if (IsGazingAtObject)
    {
        // ヒットした(フォーカスを当てた)オブジェクトと衝突地点をプロパティにセット
        HitObject = HitInfo.collider.gameObject;
        HitPosition = HitInfo.point;
        // 視線Rayの距離を記録
        lastHitDistance = HitInfo.distance;
    }
    // ヒットしなかった場合(フォーカスが外れてる場合)
    else
    {
        HitObject = null;
        // 視線Ray方向に記録している視線Rayの距離だけ進んだ地点をセット
        HitPosition = GazeOrigin + (GazeNormal * lastHitDistance);
    }
    return previousFocusObject;
}

視線Rayとオブジェクトが衝突しなくてもHitPositionに値がセットされています。これはカーソル(DefaultCursorやBasicCursor)の位置を行進するのにHitPositionを参照しているためです。

uGUIに対してRayを飛ばし、3DオブジェクトとuGUIのどちらかをフォーカスする

Physics.RaycastではuGUIに対して衝突判定ができないので、EventSystem.RaycastAllを使って別途視線Rayを飛ばします。

private void RaycastUnityUI()
{
    // イベントデータがまだ作られていないのならば、ここで作っておく
    if (UnityUIPointerEvent == null)
    {
        UnityUIPointerEvent = new PointerEventData(EventSystem.current);
    }
    // カーソル位置をスクリーン座標に変換
    Vector2 cursorScreenPos = Camera.main.WorldToScreenPoint(HitPosition);
    // 前フレームのデータを使ってデルタを計算する
    UnityUIPointerEvent.delta = cursorScreenPos - UnityUIPointerEvent.position;
    // イベントデータにセットしておく
    UnityUIPointerEvent.position = cursorScreenPos;
    // キャッシュをクリアする
    raycastResultList.Clear();
    // カーソル位置(スクリーン座標)からuGUIに対してRaycastする
    EventSystem.current.RaycastAll(UnityUIPointerEvent, raycastResultList);
    // もっとも近傍でヒットしたuGUIのRaycastResultを取得する
    RaycastResult uiRaycastResult = FindClosestRaycastHitInLayerMasks(raycastResultList, RaycastLayerMasks);
    UnityUIPointerEvent.pointerCurrentRaycast = uiRaycastResult;
    // uGUIにヒットしていなければ何もしない
    // uGUIがヒットしていれば、3Dとどちらを優先するかを判定する
    if (uiRaycastResult.gameObject != null)
    {
        // uGUIを優先するかどうかのフラグ
        bool superseded3DObject = false;
        if (IsGazingAtObject)
        {
            // LayerMaskが複数指定されている場合
            if (RaycastLayerMasks.Length > 1)
            {
                // Get the index in the prioritized layer masks
                int uiLayerIndex = FindLayerListIndex(uiRaycastResult.gameObject.layer, RaycastLayerMasks);
                int threeDLayerIndex = FindLayerListIndex(hitInfo.collider.gameObject.layer, RaycastLayerMasks);
                if (threeDLayerIndex > uiLayerIndex)
                {
                    superseded3DObject = true;
                }
                else if (threeDLayerIndex == uiLayerIndex)
                {
                    if (hitInfo.distance > uiRaycastResult.distance)
                    {
                        superseded3DObject = true;
                    }
                }
            }
            // 単一の場合
            else
            {
                // 素直に距離を比較して、短い方を優先する
                if (hitInfo.distance > uiRaycastResult.distance)
                {
                    superseded3DObject = true;
                }
            }
        }
        // 3Dオブジェクトにヒットしていない、もしくはuGUI優先フラグが立っている
        if (!IsGazingAtObject || superseded3DObject)
        {
            // フォーカスは当てているフラグを立てる
            IsGazingAtObject = true;
            // 衝突地点(ワールド座標)を計算し、RaycastHitを作成
            Vector3 worldPos = Camera.main.ScreenToWorldPoint(new Vector3(uiRaycastResult.screenPosition.x, uiRaycastResult.screenPosition.y, uiRaycastResult.distance));
            hitInfo = new RaycastHit
            {
                distance = uiRaycastResult.distance,
                normal = -Camera.main.transform.forward,
                point = worldPos
            };
            // 各プロパティを設定
            HitObject = uiRaycastResult.gameObject;
            HitPosition = HitInfo.point;
            lastHitDistance = HitInfo.distance;
        }
    }
}

3DオブジェクトとuGUIの両方に視線Rayが衝突した場合は、デフォルトでは単純にメインカメラとの距離が短い方にフォーカスが当たることとなります。レイヤーマスクが複数指定された場合は挙動が変わるので、気になる人は確認してみてください。
イベントの発火についてはUpdateの最後に書いてある通りです。

まとめ

HoloToolkit-Unityの入力系を制御するInputManagerについて、Gaze部分についてまとめました。次回はジェスチャー操作についてまとめます。