夜風のMixedReality

xRと出会って変わった人生と出会った技術を書き残すためのGeekなHoloRangerの居場所

HoloLensで使えるオリジナルのUIを作成する  Dial HoloLensアドベントカレンダー22日目

本日はオリジナルUI記事です。

〇Dial型UI

HoloLens 2ではオブジェクトをつかんで回転することができます。

今回はこの機能を応用して回転角度に応じてイベントを引き起こすオリジナルUIを作成します。

〇動画

youtu.be

リファクタリングが完了したらMRTKに機能を提供しようと思います。

〇実装

今回の機能は次のようになります。

ダイアルが一軸で回転できる

ダイアルの回転を制限できる

・回転の割合に応じて他コンポーネントで使用可能な値が算出される

ダイアル自体にも回転角度に応じてイベントが発動できるようにする。

ダイアルの作成

今回は見た目上回転して見えるダイアルと、実際にユーザーがつかんで操作する見えないオブジェクトの2つのパーツで作成します。

この方式はMRTK2.5で実験的に導入されているJoyStickに近い考えになります。

redhologerbera.hatenablog.com

redhologerbera.hatenablog.com

redhologerbera.hatenablog.com

①hierarchyウィンドウから[Create]で[Cylinder]を作成、[DialGrabObject]と名付けます。

 [Dial]のスケールはx,y,z=0.1、0.02、0.1に指定します。

f:id:Holomoto-Sumire:20201222100445j:plain

②hierarchyウィンドウから[Create]で[DialGrabObject]の親オブジェクトとなる空のゲームオブジェクトを作成し[Rotatable_Dial_HoloLens2]と名付けます。

これは[PressableButtonHoloLens 2]に合わせました。

f:id:Holomoto-Sumire:20201222101352j:plain

③[Rotatable_Dial_HoloLens2]の子オブジェクトに[Dial]を配置します。

f:id:Holomoto-Sumire:20201222102446j:plain

ここからDialを回転できるようにします。

④[DialGrabObject]オブジェクトに[ObjectManipulator]コンポーネントを追加します。

f:id:Holomoto-Sumire:20201222104930j:plain

[ObjectManipulator]コンポーネントを加えることでユーザーのジェスチャーによってオブジェクトが移動できるようになります。

⑤[Dial]オブジェクトに[NearInteractionGrabbable]コンポーネントを加えます。

f:id:Holomoto-Sumire:20201222105512j:plain

[NearInteractionGrabbable]コンポーネントによってHoloLens 2の[HandGesture]のGrab(つかむ)動作でオブジェクトを移動できるようになりました。

f:id:Holomoto-Sumire:20201222113420g:plain

このままではオブジェクトが自由自在に移動してしまうので制限を加えます。

⑥[DialGrabObject]オブジェクトに[Move Axis Constraint]コンポーネントを加えます。

f:id:Holomoto-Sumire:20201222112855j:plain

これでオブジェクトの移動に制限を与えることができます。

⑦[Move Axis Constraint]コンポーネントの設定を次のようにします。

[Constraint On Movement]を[Everything]に指定します。

f:id:Holomoto-Sumire:20201222113606j:plain

⑧[DialGrabObject]オブジェクトに[Rotation Axis Constraint]コンポーネントを加えます。

[Move Axis Constraint]コンポーネント同様こちらは回転を制限します。

f:id:Holomoto-Sumire:20201222113742j:plain

⑨[Constraint On Rotetion]を[XAxis]、[YAxis]を指定します。

f:id:Holomoto-Sumire:20201222113920j:plain

⑩[Use Local Space For Constraint]にチェックを入れ有効にします。

f:id:Holomoto-Sumire:20201222143726j:plain

ここにチェックを入れることで制限の軸をローカル座標に限定することができます。

これで[DialGrabObject]オブジェクトがY軸のみに回転するようになります。

f:id:Holomoto-Sumire:20201222115040j:plain

〇Dialの機能の作成

①[Rotatable_Dial_HoloLens2]オブジェクトに[RotatableDialHoloLens2Manager]という名前の新規のコンポーネントを作成します。

f:id:Holomoto-Sumire:20201222144916j:plain

今回は次のようなスクリプトを作成しました。

using System.Collections;
using System.Collections.Generic;

using UnityEngine;
using UnityEngine.Events;


public class RotatableDialHoloLens2Manager : MonoBehaviour
{
    [SerializeField]
    GameObject _grubObject;//つかんで動かすオブジェクト
    [SerializeField]
    GameObject _visualdialObject;//見た目上のオブジェクト
    [SerializeField]
    GameObject _visualObjectRoot;//回転角度補正用
    [SerializeField]
    float _maxAngle;//オブジェクトの回転の最大角度
    [SerializeField]
    float _minAngle;//オブジェクトの回転数の最小角度

    float _differenceRot;
    float _oldRot;
    int _rotationCnt = -1;
    float _rotationSave;

    public float _value;

    bool _isOneTimeValueEvent = false;
    public UnityEvent _maxValueEvent;
    public UnityEvent _minValueEvent;

    [SerializeField] GradeEvents[] _inputDialEvents;
    [System.Serializable]
    class GradeEvents
    {
        public float _maxValue;
        public float _minValue;
        public UnityEvent _dialEvent;
    }

    void Update()
    {
        _rotationSave = _grubObject.transform.localRotation.eulerAngles.z;//つかんで回すものの角度を保存(これをもとに計算する)
        _differenceRot = _rotationSave - _oldRot;//1フレームごとの回転の差(回転の向きを決めるのに使う&回転数を取得するのに使う)
        _oldRot = _rotationSave;

        //回転の差が-300か300(0から360に切り替わる瞬間があるため)になると回転数をプラス1する
        if (_differenceRot <= -300f)
        {
            _rotationCnt--;
        }
        else if (_differenceRot >= 300f)
        {
            _rotationCnt++;
        }

        if (_visualObjectRoot.transform.rotation.eulerAngles.y > 90f && _visualObjectRoot.transform.rotation.eulerAngles.y < 270f)//回転が逆になるバグ解消
        {
            var displayAngle = -(int)((360f - _rotationSave) + _rotationCnt * 360);
            //つまみが回る最大と最小を計算
            if (displayAngle >= _maxAngle)
            {
                _rotationSave = -(360f - _maxAngle) + _rotationCnt * 360;
                _grubObject.transform.rotation = Quaternion.Euler(_grubObject.transform.rotation.eulerAngles.x, _grubObject.transform.rotation.eulerAngles.y, -(360f - _maxAngle) + _rotationCnt * 360);
            }
            else if (displayAngle <= _minAngle)
            {
                _rotationSave = -(360f - _minAngle) + _rotationCnt * 360;
                _grubObject.transform.rotation = Quaternion.Euler(_grubObject.transform.rotation.eulerAngles.x, _grubObject.transform.rotation.eulerAngles.y, -(360f - _minAngle) + _rotationCnt * 360);
            }


            _visualdialObject.transform.localRotation = Quaternion.Euler(0f, _rotationSave, 0f); //計算した結果をつまみに代入

            //改めて表示用の数値を計算
            displayAngle = -(int)((360f - _rotationSave) + _rotationCnt * 360);
            _value = displayAngle / (_maxAngle - _minAngle) * 100; //パーセント
            //表示
            //  _dialAngleText.text = displayAngle+"°"+"      "+(int)_value+"%";
        }
        else
        {
            var displayAngle = (int)((360f - _rotationSave) + _rotationCnt * 360);

            //つまみが回る最大と最小を計算
            if (displayAngle >= _maxAngle)
            {
                _rotationSave = (360f - _maxAngle) + _rotationCnt * 360;
                _grubObject.transform.rotation = Quaternion.Euler(_grubObject.transform.rotation.eulerAngles.x, _grubObject.transform.rotation.eulerAngles.y, (360f - _maxAngle) + _rotationCnt * 360);
            }
            else if (displayAngle <= _minAngle)
            {
                _rotationSave = (360f - _minAngle) + _rotationCnt * 360;
                _grubObject.transform.rotation = Quaternion.Euler(_grubObject.transform.rotation.eulerAngles.x, _grubObject.transform.rotation.eulerAngles.y, (360f - _minAngle) + _rotationCnt * 360);
            }

            _visualdialObject.transform.localRotation = Quaternion.Euler(0f, -_rotationSave, 0f); //計算した結果をつまみに代入

            //改めて表示用の数値を計算
            displayAngle = (int)((360f - _rotationSave) + _rotationCnt * 360);
            _value = displayAngle / (_maxAngle - _minAngle) * 100; //パーセント
            //表示
            //  _dialAngleText.text = displayAngle+"°"+"      "+(int)_value+"%";
        }

        //Event関係
        for (int i = 0; i < _inputDialEvents.Length; i++)
        {
            if (_value >= _inputDialEvents[i]._minValue && _value <= _inputDialEvents[i]._maxValue)
            {
                _inputDialEvents[i]._dialEvent.Invoke();
            }
        }
        //Valueが最大、または最小になった時(ダイアルが動かなくなったとき)
        if (_value >= 100f)
        {
            if (!_isOneTimeValueEvent)
            {
                _maxValueEvent.Invoke();
                _isOneTimeValueEvent = true;
            }
        }
        else if (_value <= 0f)
        {
            if (!_isOneTimeValueEvent)
            {
                _minValueEvent.Invoke();
                _isOneTimeValueEvent = true;
            }
        }
        else
        {
            _isOneTimeValueEvent = false;
        }
    }

②[Rotatable_Dial_HoloLens2]オブジェクトの子オブジェクトに[DialGrabObjectRoot]という名前の空のオブジェクトを作成し、その子オブジェクトに[DialGrabObject]オブジェクトを配置します。

f:id:Holomoto-Sumire:20201222151103j:plain

③[Rotatable_Dial_HoloLens2]オブジェクトのinspectorウィンドウから[RotatableDialHoloLens2Manager]コンポーネントの、[grabObject]に[DialGrabObject]を指定します。

f:id:Holomoto-Sumire:20201222151254j:plain

④ここまでの設定で[MinAngle]から[MaxAngle]の間を回転するようになります。

f:id:Holomoto-Sumire:20201222151721j:plain

⑤最後に見た目上の回転させたいオブジェクトをシーンに配置、[Rotatable_Dial_HoloLens2]オブジェクトの[RotatableDialHoloLens2Manager]コンポーネントの[visualdialObject]に設定します。

Unityの回転の問題で角度によっては値が反転してしまうことがあるため今回はもう一つ設定を用意しています。

⑥[visualObjectRoot]に回転させたいオブジェクトの親オブジェクトを指定します。

以上で準備が完了しました。

MaxとMinで指定した角度間を回転し、回転の割合がValueとして書き出されるようになりました。

〇MRTKの機能を加えてより良いものに

今回のDialはユーザーがつかんでいるときのみ値を出す想定です。

そのためユーザーがつかんでいない場合Updateの処理をしないように設定します。

①[DialGrabObject]オブジェクトに[Interactable]コンポーネントを加えます。

f:id:Holomoto-Sumire:20201222210422j:plain

[Interactable]コンポーネントはAirTapの検知とイベントなどに使用されるコンポーネントですが、基本的な考え方として「アクションに関しての検知とリアクション」の機能を持っています。

今回握っていることを検知するために使用します。

コンポーネントを次のように書き加えます。

using System.Collections;
using System.Collections.Generic;

using UnityEngine;
using UnityEngine.Events;
using Microsoft.MixedReality.Toolkit.UI;//追加
public class RotatableDialHoloLens2Manager : MonoBehaviour
{
  ...
    bool _isOneTimeValueEvent = false;
    public UnityEvent _maxValueEvent;
    public UnityEvent _minValueEvent;
    bool _isGlabed;//追加
    [SerializeField]
    Interactable _interactable;//追加
 InteractableOnGrabReceiver receiver;//追加
 ...

void Start()//追加
    {
        receiver = _interactable.AddReceiver<InteractableOnGrabReceiver>();
        receiver.OnGrab.AddListener(() => _isGlabed = true);
        receiver.OnRelease.AddListener(() => _isGlabed = false);
    }//ここまで追加
    void Update()
    {
        if (_isGlabed)//追加
        {
             ...
         }
    }
}

これで_isGlabedが真のときにしか処理が行われなくなりました。

[InteractableOnGrabReceiver]はInteractableコンポーネントから検知の情報を受け取る型です。

Start関数では[Interactable]コンポーネントの握ることを検知する[OnGrab]の検知情報を設定し、つかんでいる状態の場合_isGrabedがtrue,それ以外の場合falseになります。

これによって握っている状態の場合のみダイアルの処理が行われるようになりました。

③[Rotatable_Dial_HoloLens2]オブジェクトの[RotatableDialHoloLens2Manager]コンポーネントの[Interactable]に[DialGrabObject]オブジェクトの[Interactable]コンポーネントをアタッチします。

f:id:Holomoto-Sumire:20201222212247j:plain

以上ですべての設定が完了しました。

〇RotatableDialHoloLens2Manager

using System.Collections;
using System.Collections.Generic;

using UnityEngine;
using UnityEngine.Events;
using Microsoft.MixedReality.Toolkit.UI;

public class RotatableDialHoloLens2Manager : MonoBehaviour
{
    [SerializeField]
    GameObject _grubObject;//つかんで動かすオブジェクト
    [SerializeField]
    GameObject _visualdialObject;//見た目上のオブジェクト
    [SerializeField]
    GameObject _visualObjectRoot;//回転角度補正用
    [SerializeField]
    float _maxAngle;//オブジェクトの回転の最大角度
    [SerializeField]
    float _minAngle;//オブジェクトの回転数の最小角度

    [SerializeField]
    Interactable _interactable;
    InteractableOnGrabReceiver receiver;

    float _differenceRot;
    float _oldRot;
    int _rotationCnt = -1;
    float _rotationSave;

    public float _value;

    bool _isOneTimeValueEvent = false;
    public UnityEvent _maxValueEvent;
    public UnityEvent _minValueEvent;
    bool _isGlabed;

    [SerializeField] GradeEvents[] _inputDialEvents;
    [System.Serializable]
    class GradeEvents
    {
        public float _maxValue;
        public float _minValue;
        public UnityEvent _dialEvent;
    }
    void Start()
    {
        receiver = _interactable.AddReceiver<InteractableOnGrabReceiver>();
        receiver.OnGrab.AddListener(() => _isGlabed = true);
        receiver.OnRelease.AddListener(() => _isGlabed = false);
    }

    void Update()
    {
        if (_isGlabed)
        {
            _rotationSave = _grubObject.transform.localRotation.eulerAngles.z;//つかんで回すものの角度を保存(これをもとに計算する)
            _differenceRot = _rotationSave - _oldRot;//1フレームごとの回転の差(回転の向きを決めるのに使う&回転数を取得するのに使う)
            _oldRot = _rotationSave;

            //回転の差が-300か300(0から360に切り替わる瞬間があるため)になると回転数をプラス1する
            if (_differenceRot <= -300f)
            {
                _rotationCnt--;
            }
            else if (_differenceRot >= 300f)
            {
                _rotationCnt++;
            }

            if (_visualObjectRoot.transform.rotation.eulerAngles.y > 90f && _visualObjectRoot.transform.rotation.eulerAngles.y < 270f)//回転が逆になるバグ解消
            {
                var displayAngle = -(int)((360f - _rotationSave) + _rotationCnt * 360);
                //つまみが回る最大と最小を計算
                if (displayAngle >= _maxAngle)
                {
                    _rotationSave = -(360f - _maxAngle) + _rotationCnt * 360;
                    _grubObject.transform.rotation = Quaternion.Euler(_grubObject.transform.rotation.eulerAngles.x, _grubObject.transform.rotation.eulerAngles.y, -(360f - _maxAngle) + _rotationCnt * 360);
                }
                else if (displayAngle <= _minAngle)
                {
                    _rotationSave = -(360f - _minAngle) + _rotationCnt * 360;
                    _grubObject.transform.rotation = Quaternion.Euler(_grubObject.transform.rotation.eulerAngles.x, _grubObject.transform.rotation.eulerAngles.y, -(360f - _minAngle) + _rotationCnt * 360);
                }


                _visualdialObject.transform.localRotation = Quaternion.Euler(0f, _rotationSave, 0f); //計算した結果をつまみに代入

                //改めて表示用の数値を計算
                displayAngle = -(int)((360f - _rotationSave) + _rotationCnt * 360);
                _value = displayAngle / (_maxAngle - _minAngle) * 100; //パーセント
                                                                       //表示
                                                                       //  _dialAngleText.text = displayAngle+"°"+"      "+(int)_value+"%";
            }
            else
            {
                var displayAngle = (int)((360f - _rotationSave) + _rotationCnt * 360);

                //つまみが回る最大と最小を計算
                if (displayAngle >= _maxAngle)
                {
                    _rotationSave = (360f - _maxAngle) + _rotationCnt * 360;
                    _grubObject.transform.rotation = Quaternion.Euler(_grubObject.transform.rotation.eulerAngles.x, _grubObject.transform.rotation.eulerAngles.y, (360f - _maxAngle) + _rotationCnt * 360);
                }
                else if (displayAngle <= _minAngle)
                {
                    _rotationSave = (360f - _minAngle) + _rotationCnt * 360;
                    _grubObject.transform.rotation = Quaternion.Euler(_grubObject.transform.rotation.eulerAngles.x, _grubObject.transform.rotation.eulerAngles.y, (360f - _minAngle) + _rotationCnt * 360);
                }

                _visualdialObject.transform.localRotation = Quaternion.Euler(0f, -_rotationSave, 0f); //計算した結果をつまみに代入

                //改めて表示用の数値を計算
                displayAngle = (int)((360f - _rotationSave) + _rotationCnt * 360);
                _value = displayAngle / (_maxAngle - _minAngle) * 100; //パーセント
                                                                       //表示
                                                                       //  _dialAngleText.text = displayAngle+"°"+"      "+(int)_value+"%";
            }

            //Event関係
            for (int i = 0; i < _inputDialEvents.Length; i++)
            {
                if (_value >= _inputDialEvents[i]._minValue && _value <= _inputDialEvents[i]._maxValue)
                {
                    _inputDialEvents[i]._dialEvent.Invoke();
                }
            }
            //Valueが最大、または最小になった時(ダイアルが動かなくなったとき)
            if (_value >= 100f)
            {
                if (!_isOneTimeValueEvent)
                {
                    _maxValueEvent.Invoke();
                    _isOneTimeValueEvent = true;
                }
            }
            else if (_value <= 0f)
            {
                if (!_isOneTimeValueEvent)
                {
                    _minValueEvent.Invoke();
                    _isOneTimeValueEvent = true;
                }
            }
            else
            {
                _isOneTimeValueEvent = false;
            }

        }
    }
}

〇使い方

ここまでで作り方をご紹介しましたので、ここからは使い方を改めてまとめます。

①[RotatableDialHoloLens2Manager]コンポーネントをすべての親オブジェクトにアタッチする。

f:id:Holomoto-Sumire:20201222212923j:plain

②[GameObject]には[ObjectManipulator]コンポーネントをアタッチして一軸のみの回転を行うオブジェクトをアタッチする。

f:id:Holomoto-Sumire:20201222213011j:plain

③[visualdialObject]には見た目上の回転するオブジェクトをアタッチします。

f:id:Holomoto-Sumire:20201222213042j:plain

 [GameObject]とは異なり回転に関する制限などが反映されます。

④[visualdialObjectRoot]は[visualdialObject]の親オブジェクトをアタッチします。これは回転の向きを取得するために使用します。

f:id:Holomoto-Sumire:20201222213209j:plain

⑤[MinAngle]、[MaxAngle]には回転させたい角度をしています。例えばMinAngle=0、MaxAngle=90とした場合0°から90°までの間で回転し、それ以外の角度では回転が固定されます。

f:id:Holomoto-Sumire:20201222213926j:plain

⑥[Interactable]には[GameObject]にアタッチした[Interactable]コンポーネントをアタッチします。これはつかんでいる状態を検知、取得します。

f:id:Holomoto-Sumire:20201222213417j:plain

⑦[Value]は回転の角度に応じて0~100の値が出ます。これは他のコンポーネントと組み合わせてUIのリアクションとして使用することができます。

f:id:Holomoto-Sumire:20201222213541j:plain

⑧[MinValueEvent][MaxValueEvent]のイベントはそれぞれ、回転角度が最大、最小の値を撮った際にそれぞれ発火するイベントです。

f:id:Holomoto-Sumire:20201222213701j:plain

⑨[inputDialEvents]は任意の数のイベントを作成できます。ここでも角度を指定し、その角度内に回転がある場合イベントが発動します。

f:id:Holomoto-Sumire:20201222214255j:plain

以上がDialの作り方・使い方でした。

〇HoloLensアドベントカレンダーとは?

冒頭でもお知らせしましたが、本日の記事はHoloLensアドベントカレンダー22日目の記事になります。

qiita.com

明日は熊本を拠点に活動されるHoloRanger、Shoさんの『HoloLens2チュートリアルの補足メモ(後編)』です。

前編もとても参考になる面白い内容でしたので楽しみです。

sy0690.hateblo.jp