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

投稿者: | 2017-09-19

前回は入力系を制御するInputManagerのGaze(視線)についてまとめましたが、今回はその続きとしてジェスチャー入力についてまとめます。ジェスチャー入力は前回のGazeと関連する部分が多くあるので、まだ確認されていない方は一読されることをおススメします。

ジェスチャー入力の仕組み

ジェスチャー入力を処理する主要なコンポーネントは、入力ソース(InputSource)とInputManagerです。

入力ソースにて手の動きを監視し、特定のジェスチャーを検出したらInputManagerに通知し、Inputmanager側にてフォーカスしているオブジェクト等に入力イベントを通知する、というのが基本的な動作です。

しかしながら、フォーカスしているオブジェクトに入力イベントが通知されるのはあくまで基本で、使い方によってはその動作をいろいろとカスタマイズすることができます。

入力イベントの伝搬

InputManagerでは特定のジェスチャーを検出したとき、以下のフローに従って入力を処理します。

まず一番最初に入力イベントが通知されるのはグローバルリスナーというラベルでカテゴライズされるGameObjectです。グローバルリスナーとして扱われるGameObjectは、オブジェクトをフォーカスしている/していないに関わらず入力イベントが通知されます。実装としては内部にGameObjectのListを保持しており、メソッドを介してグローバルリスナーにしたいGameObjectを追加したり、もしくはグローバルリスナーから除外するといったことが可能です。イベントを通知するときには、リストに入っている全てのGameObjectに対して通知されます。

public class InputManager : Singleton<InputManager>
{
    private readonly List<GameObject> globalListeners = new List<GameObject>();

    public void AddGlobalListener(GameObject listener)
    {
        globalListeners.Add(listener);
    }
    
    public void RemoveGlobalListener(GameObject listener)
    {
        globalListeners.Remove(listener);
    }

    ~中略~
}

グローバルリスナーに対して入力イベントが完了したら、次にモーダルオブジェクト、フォーカスオブジェクト、フォールバックオブジェクトのいずれかに対して入力イベントを通知します。これらには優先度が設定されていて、モーダルオブジェクトが存在しなければフォーカスオブジェクト(Gazeしているオブジェクト)、フォーカスオブジェクトが存在しなければフォールバックオブジェクトに、といった順番でイベント通知を試みます。いずれかのオブジェクトにてイベントがサブスクライブされたならば、以降のオブジェクトに対しては入力イベントは通知されません。

フォーカスオブジェクトはGazeManagerから取得できるものを使いますが、モーダルオブジェクトとフォールバックオブジェクトはInputManagerにてスタックとして管理されています。ただ、実際にイベント通知が行われるのはスタックの先頭にいるGameObjectのみです。(グローバルリスナーとは違う)

public class InputManager : Singleton<InputManager>
{
    public GameObject OverrideFocusedObject { get; set; }

    private readonly Stack<GameObject> modalInputStack = new Stack<GameObject>();
    private readonly Stack<GameObject> fallbackInputStack = new Stack<GameObject>()
    
    /// モーダルスタックにGameObjectをpushする
    /// モーダルスタックに入ったGameObjectはGazeされているされていないに関わらず、
    /// 入力イベントをサブスクライブする
    public void PushModalInputHandler(GameObject inputHandler)
    {
        modalInputStack.Push(inputHandler);
    }

    /// モーダルスタックからGameObjectをpop
    public void PopModalInputHandler()
    {
        modalInputStack.Pop();
    }

    /// モーダルスタックをクリア
    public void ClearModalInputStack()
    {
        modalInputStack.Clear();
    }

    /// フォールバックスタックにGameObjectをpush
    /// モーダルにもGazeにもオブジェクトがなければ
    /// フォールバックスタックに入っているオブジェクトが入力イベントをサブスクライブする
    public void PushFallbackInputHandler(GameObject inputHandler)
    {
        fallbackInputStack.Push(inputHandler);
    }

    /// Remove the last game object from the fallback input stack.
    public void PopFallbackInputHandler()
    {
        fallbackInputStack.Pop();
    }

    /// Clear all fallback input handlers off the stack.
    public void ClearFallbackInputStack()
    {
        fallbackInputStack.Clear();
    }
}

モーダルオブジェクトの使い道としては、プリインアプリのHologramsのようにドラッグ感覚でオブジェクトを移動させたいときに使えると思います。前述した通り、InputManagerでは特に制御をしない限り、GazeでフォーカスをあてているGameObjectだけに入力イベントが通知されます。すなわち、ドラッグ操作中にオブジェクトからフォーカスを外してしまうと、その場でオブジェクトの移動が中断されてしまいます。オブジェクトを動かしながらフォーカスを当て続けるというのは、やってみればわかると思いますがかなり辛いです。

そこでドラッグ開始時にモーダルスタックに移動させたいオブジェクトを追加すれば、ドラッグ中はGazeに関係なく入力イベントが通知されるので、フォーカスを外しても移動操作を継続することができます。具体的なコードとしては、MixedRealityToolkitにあるHandDraggable.csが参考になるので、気になる方は見てみてください。

フォールバックオブジェクトの使い道としては、メニューの表示/非表示などに使えるかもしれません。フォールバックオブジェクトはモーダルオブジェクトにもフォーカスオブジェクトも存在しない場合にのみ入力イベントが通知されます。したがってオブジェクトにフォーカスを当てていない状態でジェスチャーを検出したときのみメニューオブジェクトを表示する、といった実装が可能です。

最後にフォーカスオブジェクトについてですが、フォーカスオブジェクトはプロパティを介して強制的に上書きすることも可能です。モーダルオブジェクトとの使い分けがちょっと微妙ですが、小ワザとして知っておくといいと思います。

GameObjectが入力イベントをサブスクライブするには?

GameObjectに入力イベントをサブスクライブさせるには、HoloToolkit-Unityで用意されているインタフェースを実装したスクリプトをアタッチします。例えば、AirTapイベントをハンドリングしたい場合はIInputClickHandlerを実装したスクリプトを用意します。

using HoloToolkit.Unity.InputModule;
using UnityEngine;

public class AirTapTest : MonoBehaviour, IInputClickHandler {

    public void OnInputClicked(InputClickedEventData eventData)
    {
        // ここにAirTapを検出したときの処理を書く
    }
}

どのようなインタフェースが用意されているかは過去の記事を参照してください。

どのようなジェスチャーを検出できるかについても、MRDesignLab向けになってはいますが過去の記事が参考になるかと思います。マニピュレーションとナビゲーションの違い、ナビゲーションとナビゲーションRailsの違いは把握しておくと良いと思います。

InputManagerを使った実践的なコードを見たいという方は、MixedRealityToolkitのHandDraggable.csやTapToPlace.cs、または私の過去の記事を見てもらうのがよいと思います。

実装

入力ソース

入力ソースについては基底クラスとしてBaseInputSourceという抽象クラスが用意されています。

namespace HoloToolkit.Unity.InputModule
{ 
    public abstract class BaseInputSource : MonoBehaviour, IInputSource
    {
        protected InputManager inputManager;
  
        protected virtual void Start()
        {
            inputManager = InputManager.Instance;
        }
        // この入力ソースがサポートする取得可能情報を確認する
        public abstract SupportedInputInfo GetSupportedInputInfo(uint sourceId);
        // 指定した情報がこの入力ソースから取得できるかどうか
        public bool SupportsInputInfo(uint sourceId, SupportedInputInfo inputInfo)
        {
            return (GetSupportedInputInfo(sourceId) & inputInfo) != 0;
        }

        public abstract bool TryGetPosition(uint sourceId, out Vector3 position);

        public abstract bool TryGetOrientation(uint sourceId, out Quaternion orientation);
    }
}

ここで入力ソースからどのような情報を取得できるのかを示す、SupportedInputInfoという列挙体が用いられています。

namespace HoloToolkit.Unity.InputModule
{
    /// 入力ソースがサポートする情報種別
    [Flags]
    public enum SupportedInputInfo
    {
        None = 0,           // サポートなし
        Position = 1,       // 入力ソースの位置を取得できる
        Orientation = 2,    // 入力ソースの姿勢を取得できる
    }
}

低レベルな指の状態を検出するRawInteractionSourcesでは指の位置を検出することができるので、Positionをサポートします。一方でジェスチャーを検出するGestureInputは指の位置を取得することができないのでNoneとなります。Orientation(姿勢)を取得できる入力ソースはまだありませんが、モーションコントローラがリリースされれば新たに提供されるかもしれません。

RawInteractionSourcesInput.cs

低レベルな指の状態を検出し、変化に応じてInputManagerに通知するクラスです。Updateメソッドを見るとわかる通り、毎フレーム検出状況を確認して適宜イベントを通知しています。

public class RawInteractionSourcesInput : BaseInputSource
{
    /// SourceDataについての辞書。やはり手のIDをキーとする
    private readonly Dictionary<uint, SourceData> sourceIdToData = new Dictionary<uint, SourceData>(4)

    // 現フレームで検出したすべての手のIDのハッシュ
    private readonly HashSet<uint> currentSources = new HashSet<uint>();

    /// 現フレームで新しく検出した手のIDのハッシュ
    private readonly HashSet<uint> newSources = new HashSet<uint>();
    
    private void Update()
    {
        // HashSetのクリア
        newSources.Clear();
        currentSources.Clear();
        // SourceDataのアップデートとイベント処理
        UpdateSourceData();
        // 手の検出とロストについてのイベント処理
        SendSourceVisibilityEvents();
    }
}

過去の記事でも書きましたが低レベルな指の検出では、検出のたびにインクリメントされたIDを発行します。RawInteractionSourcesInputではこのIDをキーにして検出した指のデータを管理しています。

/// 検出した指のデータを管理するクラス
private class SourceData
{
    public SourceData(uint sourceId)
    {
        SourceId = sourceId;
        HasPosition = false;
        SourcePosition = Vector3.zero;
        IsSourceDown = false;
        IsSourceDownPending = false;
        SourceStateChanged = false;
        SourceStateUpdateTimer = -1;
    }

    public readonly uint SourceId;          // 検出した手のID, InteractionSourceStateから取得
    public bool HasPosition;                // 手の位置情報が記録されているかどうか
    public Vector3 SourcePosition;          // 手の位置
    public bool IsSourceDown;               // 指が倒されているかどうか
    public bool IsSourceDownPending;        // 指の状態は、タイマーで指定した時間だけ維持されたときのみ更新される。そのためのホルダー
    public bool SourceStateChanged;         // 指の状態がフレームで変化したかどうか
    public float SourceStateUpdateTimer;    // 指の状態変更を管理するためのタイマー
}

毎フレーム行われるSourceDataの更新、イベントの通知は以下の通りです。

/// SourceDataを更新する
private void UpdateSourceData()
{
    // InteractionManagerから現在の手の検出状況をすべて取得する
    InteractionSourceState[] sourceStates = InteractionManager.GetCurrentReading();
    // 検出できた手が存在するなら
    if (sourceStates != null)
    {
        // 検出できた手の分だけループ
        for (var i = 0; i < sourceStates.Length; ++i)
        {
            InteractionSourceState handSource = sourceStates[i];
            // 辞書からSourceDataを取得
            SourceData sourceData = GetOrAddSourceData(handSource.source);
            // 現在検出している手のIDをハッシュを追加
            currentSources.Add(handSource.source.id);
            // SourceDataのアップデート
            UpdateSourceState(handSource, sourceData);
        }
    }
}

/// IDをキーに辞書からSourceDataを取得する。存在しなければ新しく作成して辞書に入れる。
private SourceData GetOrAddSourceData(InteractionSource interactionSource)
{
    SourceData sourceData;
    // 辞書からSourceDataの取得を試みる。存在しなければ新しく作成する。
    if (!sourceIdToData.TryGetValue(interactionSource.id, out sourceData))
    {
        // IDをもとにSourceDataを作成
        sourceData = new SourceData(interactionSource.id);
        // 辞書にいれる
        sourceIdToData.Add(sourceData.SourceId, sourceData);
        // 新規検出分のハッシュにもいれておく
        newSources.Add(sourceData.SourceId);
    }

    return sourceData;
}

/// SourceDataのアップデート。位置情報や指を倒しているかなどの情報をアップデートする。
private void UpdateSourceState(InteractionSourceState interactionSource, SourceData sourceData)
{
    // InteractionSourceStateとSourceDataのIDは共通である
    // InteractionSourceStateから手の位置情報を取得する
    Vector3 sourcePosition;
    if (interactionSource.properties.location.TryGetPosition(out sourcePosition))
    {
        sourceData.HasPosition = true;              // フラグを立てる
        sourceData.SourcePosition = sourcePosition; // 位置情報をセットする
    }

    // 指が倒されているかどうか、その状態が変化したかどうか
    if (interactionSource.pressed != sourceData.IsSourceDownPending)
    {
        sourceData.IsSourceDownPending = interactionSource.pressed;     // とりあえずの状態をセット
        sourceData.SourceStateUpdateTimer = SourcePressDelay;           // タイマーをセット
    }

    // 状態が変化してからタイマー分だけ時間が経過したら状態を確定する
    sourceData.SourceStateChanged = false;
    if (sourceData.SourceStateUpdateTimer >= 0)
    {
        float deltaTime = UseUnscaledTime
            ? Time.unscaledDeltaTime
            : Time.deltaTime;

        sourceData.SourceStateUpdateTimer -= deltaTime; // 経過時間をタイマーから引く
        // タイマー分だけ経過したら
        if (sourceData.SourceStateUpdateTimer < 0)
        {
            sourceData.IsSourceDown = sourceData.IsSourceDownPending;   // 状態を確定
            sourceData.SourceStateChanged = true;                       // フラグを立てる
        }
    }
    // イベント処理
    SendSourceStateEvents(sourceData);
}

/// 指の状態変更についてのイベント処理
private void SendSourceStateEvents(SourceData sourceData)
{
    // 指の状態が変化していたらInputManagerにイベント発火を依頼
    if (sourceData.SourceStateChanged)
    {
        // 指が倒された
        if (sourceData.IsSourceDown)
        {
            inputManager.RaiseSourceDown(this, sourceData.SourceId);
        }
        // 指が持ち上げられた
        else
        {
            inputManager.RaiseSourceUp(this, sourceData.SourceId);
        }
    }
}

/// 手の新規検出、ロスト関連のイベント処理
private void SendSourceVisibilityEvents()
{
    // 手の新規検出について、イベント発火を依頼
    foreach (uint newSource in newSources)
    {
        inputManager.RaiseSourceDetected(this, newSource);
 

    // 手のロストについて、イベント発火を依頼
    foreach (uint existingSource in sourceIdToData.Keys)
    {
        // 辞書と現フレームの情報を照らし合わせて、現フレームに辞書の情報がなければロストイベントを通いÞ
        if (!currentSources.Contains(existingSource))
        {
            pendingSourceIdDeletes.Add(existingSource);         // 削除対象に追加
            inputManager.RaiseSourceLost(this, existingSource);
        }
 

    // ロストしたものは辞書から削除
    for (int i = 0; i < pendingSourceIdDeletes.Count; ++i)
    {
        sourceIdToData.Remove(pendingSourceIdDeletes[i]);
    }
    pendingSourceIdDeletes.Clear();
}

指の状態が変わった、新しく検出した、ロストした等の状態が変化した場合はInputManagerのRaise~を呼び出してInputManagerへイベントを通知します。

GestureInput

こちらは毎度おなじみGestureRecognizerのラップなので、非常にシンプルです。

public class GesturesInput : BaseInputSource
{
    // GestureRecognizerを自動起動させるか、手動で起動するか
    public enum RecognizerStartBehavior { AutoStart, ManualStart };
    [Tooltip("Whether the recognizer should be activated on start.")]
    public RecognizerStartBehavior RecognizerStart;

    // ナビゲーション時Railsを有効化するかどうか
    [Tooltip("Set to true to use the use rails (guides) for the navigation gesture, as opposed to full 3D navigation.")]
    public bool UseRailsNavigation = false;

    // マニピュレーションとナビゲーションは競合するので、
    // GestureRecognizerは別々に用意する
    protected GestureRecognizer gestureRecognizer;
    protected GestureRecognizer navigationGestureRecognizer;

    protected override void Start()
    {
        base.Start();
        // GestureRecognizerの生成とイベントハンドラの登録
        gestureRecognizer = new GestureRecognizer();
        gestureRecognizer.TappedEvent += OnTappedEvent;
        
        gestureRecognizer.HoldStartedEvent += OnHoldStartedEvent;
        gestureRecognizer.HoldCompletedEvent += OnHoldCompletedEvent;
        gestureRecognizer.HoldCanceledEvent += OnHoldCanceledEvent;
        ~中略~
    }

    // AirTapを検出したとき
    protected void OnTappedEvent(InteractionSourceKind source, int tapCount, Ray headRay)
    {
        inputManager.RaiseInputClicked(this, 0, tapCount);
    }
    
    // 以下、そのほかのジェスチャーを検出したときも同じようにInputManagerへイベント通知
}

GestureRecognizerのイベントをサブスクライブし、ほぼそのままInputManagerへ転送しています。

InputManager

InputManagerではどのようにGameObjectへ入力イベントを通知しているのかに絞ってみていきます。AirTapを検出したときの(GestureInputからイベント通知を受けたとき)コードは以下の通りです。

public void RaiseInputClicked(IInputSource source, uint sourceId, int tapCount)
{
    // イベントデータの作成
    sourceClickedEventData.Initialize(source, sourceId, tapCount);
    // イベントのハンドリング
    HandleEvent(sourceClickedEventData, OnInputClickedEventHandler);
    // uGUI
    if (ShouldSendUnityUiEvents)
    {
        PointerEventData unityUIPointerEvent = GazeManager.Instance.UnityUIPointerEvent;
        HandleEvent(unityUIPointerEvent, ExecuteEvents.pointerClickHandler);
    }
}

// AirTapのUnityEventFunction
private static readonly ExecuteEvents.EventFunction<IInputClickHandler> OnInputClickedEventHandler =
    delegate (IInputClickHandler handler, BaseEventData eventData)
    {
        InputClickedEventData casted = ExecuteEvents.ValidateEventData<InputClickedEventData>(eventData);
        handler.OnInputClicked(casted);
    };

ジェスチャーの種類によってイベントデータの内容が若干異なりますが、基本形は変わりません。HandleEventメソッドにイベントデータとEventFunctionという関数オブジェクトを渡しています。HandleEventは以下のような実装となっています。

/// 入力イベントの処理部分
public void HandleEvent<T>(BaseEventData eventData, ExecuteEvents.EventFunction<T> eventHandler)
    where T : IEventSystemHandler
{
    // InputManagerが有効であることを確認
    if (!Instance.enabled || disabledRefCount > 0)
    {
        return;
    }
    // GazeManagerをチェックしてフォーカスを当てているオブジェクトをチェック
    // オーバーライドが設定されている場合はそちらを優先する
    GameObject focusedObject = (OverrideFocusedObject == null) ? GazeManager.Instance.HitObject : OverrideFocusedObject;
    for (int i = 0; i < globalListeners.Count; i++)
    {
        // グローバルリスナーとして設定されているGameObjectに対してイベントを通知する
        ExecuteEvents.Execute(globalListeners[i], eventData, eventHandler);
    }
    // モーダルスタックをまずは確認する
    if (modalInputStack.Count > 0)
    {
        // GameObjectの参照(popではない)
        GameObject modalInput = modalInputStack.Peek();
        // フォーカスオブジェクトとモーダルスタックに入っているオブジェクトが一致するか確認(ヒエラルキー考慮)
        if (focusedObject != null && modalInput != null && focusedObject.transform.IsChildOf(modalInput.transform))
        {
            // EventFunctionの実行を試みる。実行されたらイベント処理を終了する
            if (ExecuteEvents.ExecuteHierarchy(focusedObject, eventData, eventHandler))
            {
                return;
            }
        }
        // モーダルスタックに入っているオブジェクトに対してEventFunctionを実行する
        else
        {
            if (ExecuteEvents.ExecuteHierarchy(modalInput, eventData, eventHandler))
            {
                // EventFunctionが実行されたら終了
                return;
            }
        }
    }
    // オブジェクトがフォーカスされていたらEventFuctionの実行を試みる
    if (focusedObject != null)
    {
        bool eventHandled = ExecuteEvents.ExecuteHierarchy(focusedObject, eventData, eventHandler);
        if (eventHandled)
        {
            return;
        }
    }
    // ここまでEventFunctionが実行されなければ、フォールバックオブジェクトに対してイベント通知を試みる
    if (fallbackInputStack.Count > 0)
    {
        GameObject fallbackInput = fallbackInputStack.Peek();
        ExecuteEvents.ExecuteHierarchy(fallbackInput, eventData, eventHandler);
    }
}

GameObjectへの入力イベントの通知はUnityのEventSystemのExecuteEvents.ExecuteHierarchyを使って行われます。最初の引数にイベントを通知したいGameObject、次の引数にイベントデータ、最後の引数にはEventFunctionを渡しています。

AirTapの例ですと、最初の引数のGameObjectにIInputClickedHandler.OnInputClicked()が実装されていればそれを実行します。なおExecuteHierarchyは返り値としてboolを返し、EventFucntionがGameObjectにて実行されればtrueを、GameObjectにメソッドが実装されていないければfalseを返します。(正確にはメソッドが実装されていない場合は、再帰的に親オブジェクトを確認していき、トップレベルでも実装されていないときにfalseを返します。)

Unity UIのコードはオープンソースですので、ExecuteHierarchyがどのように実装されているか確認しておくとよいと思います。

まとめ

MixedRealityToolkit-UnityのInputManagerについて、ジェスチャー入力の部分についてまとめてみました。昔作った、ここまでの内容を活用したUIコンポーネントを少し紹介したいと思います。