あると便利?GazeでロックオンできるHoloLens UI

投稿者: | 2017-09-23

HoloLensアプリケーションを作成するうえで避けて通れないのが、アプリをどうやって操作するのか、ユーザインタフェースの設計です。特にHoloLensを初めて使う人にとってはGazeとジェスチャーを組み合わせた操作がなかなかうまくできず、よいUXが得られないことも多々あるかと思います。

今回はそのような問題を少しでも軽減できればと思い、自分が何をGazeしているのか、何を操作しようとしているのかがわかるようにするための昔つくったUIコンポーネントを紹介したいと思います。

概要

一度オブジェクトをフォーカスすると、Gazeを外してもフォーカスがそのオブジェクトにロックされます(フォーカスロック)。ロックされていますのでフォーカスを外した状態でも、AirTapをはじめとした各種ジェスチャーを認識します。

また、ジェスチャーが成功したかどうかわかるように、指を倒したときはBoundingBoxの色が変わり、指を持ち上げたときに効果音が鳴るようにしています。

使い方

MixedREalityToolkit-UnityとFocusLockSelector(今回作ったもの)をインポートします。

インポートが完了したらFocusLockManagerを適当なGameObjectにアタッチします(FocusLockManager.prefab)。

続いて、フォーカスをロックしたいオブジェクトに以下のスクリプトをアタッチします。

using HoloToolkit.Unity.InputModule;
using UnityEngine;

public class TestListener : MonoBehaviour, IInputClickHandler, IFocusLockable
{
    public void OnFocusLocked()
    {
        // フォーカスのロックが開始したときのイベントハンドラ
    }

    public void OnFocusReleased()
    {
        // フォーカスのロックが解除されたときのイベントハンドラ
    }
}

IFocusLockableというインタフェースを用意しているので、そちらを実装するだけでフォーカスロックが可能となります。AirTapやマニピュレーションとの共存も可能です。

using HoloToolkit.Unity.InputModule;
using UnityEngine;

public class TestListener : MonoBehaviour, IInputClickHandler, IFocusLockable
{
    public void OnFocusLocked()
    {
        Debug.Log("Focus Locked");
    }

    public void OnFocusReleased()
    {
        Debug.Log("Focus Released");
    }

    public void OnInputClicked(InputClickedEventData eventData)
    {
        Debug.Log("Air Tap");
    }
}

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

Is Enabled Click Sound
タップしたときに効果音を鳴らすかどうか
Click SE
タップしたときの効果音
Is Enabled Auto Release
フォーカスを外したとき、一定時間経過後にロックを解除するかどうか
Auto Release Time
フォーカスを外したとき、何秒経過後にロックを解除するか
Color When Finger Up
通常時のBoxの色
Color When Finger Down
指を倒しているときのBoxの色
Selector Box Prefab
オブジェクトを囲むBox(BoundingBoxのようなもの)のprefab

中身の解説

設計するうえで気を付けたこと

実装にあたって、やりたいこと(要件)をまとめました。

ユーザ視点

  • 操作したい/しようとしているオブジェクトが視覚的にわかること
  • 操作が不慣れで多少Gazeがオブジェクトから外れても、そのままジェスチャー操作できること⇒Gaze操作とジェスチャー操作を分離させる
  • 操作が完了したことが視覚的/聴覚的にわかること

アプリ開発者視点

  • アセットを使うときのコードの記述量を最小限にする
  • 馴染みある方法で書けること
  • 依存するライブラリMixedRealityToolkit-Unity)に手を入れることなく使えること

実装

IFocusLockableインタフェースはIInputClickHandlerと同様、UnityのIEventSystemHandlerを継承して作っています。このインタフェースはExecuteEventsでフォーカスロック/解除イベントをハンドリングするのと、Managerクラスでフォーカスロックの対象となり得るかを判断するために使用します。

using UnityEngine.EventSystems;

namespace FocusLockable
{
    // フォーカスロックの対象にするためのインタフェース
    public interface IFocusLockable : IEventSystemHandler
    {   
        // ロック開始時のハンドラ
        void OnFocusLocked();
        // ロックリリース時のハンドラ
        void OnFocusReleased();
    }
}
</code>
</pre>

Managerクラスは以下の通りです。

<pre class="line-numbers">
<code class="language-csharp">HoloToolkit.Unity;
using HoloToolkit.Unity.InputModule;
using System.Collections;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.VR.WSA.Input;

namespace FocusLockable
{
    public class FocusLockManager : Singleton<FocusLockManager> {

        [SerializeField]
        private AudioClip _clickSE;

        [SerializeField]
        private bool _isEnabledClickSound;

        // ロックを解除するまでの必要時間
        [SerializeField]
        [Range(0.0f, 5.0f)]
        private float _autoReleaseTime;

        // フォーカスを外したとき、一定時間経過でロックを解除するかどうか
        [SerializeField]
        private bool _isEnabledAutoRelease;

        // 指を持ち上げてるときのBoxの色
        [SerializeField]
        private Color _colorWhenFingerUp;

        // 指を倒しているときのBoxの色
        [SerializeField]
        private Color _colorWhenFingerDown;

        // Boxのプレファブ
        [SerializeField]
        private GameObject _selectorBoxPrefab;

        // フォーカスをロックしているGameObject
        public GameObject Target => _target;
        // 各種設定のプロパティ
        public bool IsEnabledClickSound => _isEnabledClickSound;
        public bool IsEnabledAutoRelease => _isEnabledAutoRelease;
        
        // 内部変数
        private SelectorBox _selectorBox;
        private Material _selectorBoxMaterial;
        private AudioSource _clickSoundSource;
        private bool _isTimerActive;
        private float _lockStartTime;
        private GameObject _target;
        
        private void OnEnable()
        {
            StartCoroutine(Initialize());
        }

        private void OnDisable()
        {
            GazeManager.Instance.FocusedObjectChanged -= OnFocusedObjectChanged;
            InteractionManager.SourcePressed -= OnSourcePressed;
            InteractionManager.SourceReleased -= OnSourceReleased;
            InteractionManager.SourceLost -= OnSourceLost;
        }

        private void Update()
        {
            if(_isEnabledAutoRelease)
            {
                // フォーカスが外れて一定時間経過したら、フォーカスロックをリリースする
                if(_isTimerActive && Time.unscaledTime - _lockStartTime > _autoReleaseTime)
                {
                    ExecuteEvents.Execute(_target, null, OnFocusLockReleasedEventHandler);
                    _target = null;
                    InputManager.Instance.OverrideFocusedObject = null;
                    _selectorBox.UpdateSelectorBox();
                }
            }

            if (_target != null) _selectorBox.UpdateSelectorBox();
        }

        // 初期化処理
        private IEnumerator Initialize()
        {
            // 状況によってはGazeManagerのセットアップが終わっていないこともあるので、
            // セットアップ完了を待つ
            while(true)
            {
                if (GazeManager.Initialized) break;
                else yield return null;
            }

            GazeManager.Instance.FocusedObjectChanged += OnFocusedObjectChanged;
            InteractionManager.SourcePressed += OnSourcePressed;
            InteractionManager.SourceReleased += OnSourceReleased;
            InteractionManager.SourceLost += OnSourceLost;

            // Boxの設定
            var go = Instantiate(_selectorBoxPrefab);
            _selectorBox = go.GetComponent<SelectorBox>();
            _selectorBoxMaterial = go.GetComponentInChildren<MeshRenderer>().material;
            _selectorBox.UpdateSelectorBox();

            // タップ時の効果音の設定
            _clickSoundSource = gameObject.EnsureComponent<AudioSource>();
            _clickSoundSource.playOnAwake = false;
            _clickSoundSource.clip = _clickSE;
        }

        // 指が倒されたときはBoxの色を変える
        private void OnSourcePressed(InteractionSourceState state)
        {
            _selectorBoxMaterial.color = _colorWhenFingerDown;
        }
        
        // 指をロストしたときはBoxの色を変える
        private void OnSourceLost(InteractionSourceState state)
        {
            _selectorBoxMaterial.color = _colorWhenFingerUp;
        }

        // 指が持ち上げられたときはタップ完了とみなし、条件があったときに効果音を鳴らす        
        private void OnSourceReleased(InteractionSourceState state)
        {
            // ターゲットが設定されており、Boxの色が指を倒しているときにのみ音を鳴らす
            // MixedRealityToolkitでのAirTapジェスチャーの仕様として、
            // 指を倒しているときに他のオブジェクトをへフォーカスが移るとゆ、指を持ち上げてもジェスチャーは認識されない
            // また、このスクリプトでも他のオブジェクトへフォーカスが移った場合は色を初期に戻すようにしているので、
            // Boxの色から効果音を鳴らすべきかどうかを判定している
            if (_isEnabledClickSound && _clickSE != null && _target != null && _selectorBoxMaterial.color == _colorWhenFingerDown)
                _clickSoundSource.Play();

            // BoxのTransformを更新
            _selectorBoxMaterial.color = _colorWhenFingerUp;
        }

        // フォーカスしているオブジェクトが変化したときのハンドラ(GazeManagerのイベントに対するハンドラ)
        private void OnFocusedObjectChanged(GameObject previousObject, GameObject newObject)
        {   
            if(newObject != null)
            {
                // オブジェクト、またはその親がインタフェースを実装していることを確認する
                var go = newObject;
                while(true)
                {
                    if (go.GetComponent<IFocusLockable>() != null) break;
                    else
                    {
                        if (go.transform.parent == null) return;
                        else go = go.transform.parent.gameObject;
                    }
                }

                // フォーカスをロックしている最中はタイマーを止める
                if (_isEnabledAutoRelease) _isTimerActive = false;
                // 新しくフォーカスしたオブジェクトが、フォーカスをロックしているオブジェクトと異なる場合
                if(_target != go)
                {
                    // 旧ロック対象に対して、ロックリリースを通知
                    ExecuteEvents.Execute(_target, null, OnFocusLockReleasedEventHandler);
                    // ロック対象を入れ替えて、ロック開始を通知
                    _target = go;
                    _selectorBoxMaterial.color = _colorWhenFingerUp;
                    InputManager.Instance.OverrideFocusedObject = _target;
                    ExecuteEvents.Execute(_target, null, OnFocusLockedEventHandler);
                }
            }

            // フォーカスを外したとき、ロックのオートリリースが有効ならタイマーを開始する
            if(newObject == null && _isEnabledAutoRelease)
            {
                _lockStartTime = Time.unscaledTime;
                _isTimerActive = true;
            }
        }
        
        // ロック開始時のEventFunction
        private static readonly ExecuteEvents.EventFunction<IFocusLockable> OnFocusLockedEventHandler =
            delegate (IFocusLockable handler, BaseEventData eventData)
            {
                handler.OnFocusLocked();
            };

        // ロックリリース時のEventFunction
        private static readonly ExecuteEvents.EventFunction<IFocusLockable> OnFocusLockReleasedEventHandler =
            delegate (IFocusLockable handler, BaseEventData eventData)
            {
                handler.OnFocusReleased();
            };
    }
}

BoundingBox(ここではSelectorBox)についてはMRDesignLabとほぼ同様です。気になる人はリポジトリを参照してください。

まとめ

過去2回の記事で得られる内容でちょっとしたUIコンポーネントを作ってみました。使用するライブラリの中身を知っておくと割と簡単に拡張できるようになるので、ライブラリの調査は結構楽しいです。

次回はMixedRealityToolkit-Unityの音声操作周りを調べていこうと思います。