凹みTips

C++、JavaScript、Unity、ガジェット等の Tips について雑多に書いています。

Unity 4.6 から利用できる UI での EventSystem をカスタムする方法について調べてみた

はじめに

Unity 4.6 から導入された新 UI システムでは、マウスやキーボード、キーパッドの入力を担当する Standalone Input Module と、タッチを担当する Touch Input Module によって、入力イベントが扱われます。何かしらの UI 要素を追加すると、自動でこれらのモジュール及び EventSystem のアタッチされたゲームオブジェクトが自動で生成されます。

f:id:hecomi:20140925204359p:plain

そして新 UI システムでは、CanvasRender ModeWorld Space にすることで、VR のシーンでも簡単に扱うことが出来ます。が、そのままではマウスやキーボードの操作になってしまい、色んなガジェットと組み合わせたりしながら VR 内で望ましい UI を色々と試行錯誤するにはカスタムしてあげる必要があります。

そこで本エントリでは、独自のイベントハンドリングを新 UI システムで行う方法について調べてみた内容を共有したいと思います。

参考

既に VR 用に実装されている方がいらっしゃいますので、これが何をしているか理解するところまでやります。

あとはドキュメント、及び Gist に上がった各モジュールのコードを参考に追っていきます。

イベント送信の仕組み概要

イベントを送信するのは各 Input Module の基底クラス BaseInputModule が担当します。これは MonoBehaviour を継承した UIBehaviour を継承したクラスのため、GameObject にアタッチすることができます。カスタムしたモジュールのコードをドキュメントを参考にして書いてみます。

using UnityEngine;
using UnityEngine.EventSystems;

public class CustomInputModule : BaseInputModule
{
    public GameObject[] targetObjects;

    public override void Process()
    {
        if (targetObjects == null || targetObjects.Length == 0) {
            return;
        }

        foreach (var target in targetObjects) {
            ExecuteEvents.Execute(target, new BaseEventData(eventSystem), ExecuteEvents.submitHandler);
        }
    }
}

Process()Update() のように毎フレーム呼ばれる関数です(BaseInputModule 内で呼ばれます)。ここで対象となる GUI の GameObject を判断し、ExecuteEvents.Execute() を通じて対象の GameObject に登録されたハンドラを呼び出します。引数は順番に、対象の GameObejct、引数、ハンドラとなっています。Inspector から適当なボタンを targetObjects に登録しておくと、毎フレームクリックされる形になりますので試してみてください。

おまけ

更に進んでカスタムイベントを作成するには以下の Gist がとても参考になります。

StandaloneInputModule を見てみる

概要が掴めたので StandaloneInputModule を見てみると何となく概要が掴めると思います。

namespace UnityEngine.EventSystems
{
    [AddComponentMenu("Event/Standalone Input Module")]
    public class StandaloneInputModule : PointerInputModule
    {
        // ...(略)
        
        public override void Process()
        {
            bool usedEvent = SendUpdateEventToSelectedObject ();

            if (!usedEvent)
                usedEvent |= SendMoveEventToSelectedObject ();

            if (!usedEvent)
                SendSubmitEventToSelectedObject ();

            ProcessMouseEvent ();
        }

        // ...(略)

        private void ProcessMouseEvent()
        {
            bool pressed = Input.GetMouseButtonDown (0);
            bool released = Input.GetMouseButtonUp (0);

            var pointerData = GetMousePointerEventData ();

            // Take care of the scroll wheel
            float scroll = Input.GetAxis ("Mouse ScrollWheel");
            pointerData.scrollDelta.x = 0f;
            pointerData.scrollDelta.y = scroll;

            if (!UseMouse (pressed, released, pointerData))
                return;

            // Process the first mouse button fully
            ProcessMousePress (pointerData, pressed, released);
            ProcessMove (pointerData);

            if (!Mathf.Approximately (scroll, 0.0f))
            {
                var scrollHandler = ExecuteEvents.GetEventHandler<IScrollHandler> (pointerData.pointerCurrentRaycast.go);
                ExecuteEvents.ExecuteHierarchy (scrollHandler, pointerData, ExecuteEvents.scrollHandler);
            }
        }

        // ...(略)
    }
}

Process() を見てみると ProcessMouseEvent()Input.GetMouse*() をしているのが分かります。ここで得られたデータを元に色々なイベントに変換して、対象の GameObject に対して ExecuteEvents.Execute() してイベントハンドラを呼び出しています。では対象の GameObject はどうやって取得しているか見てみます。

ProcessMouseEvent() の中で GetMousePointerEventData() を呼び出しているのが見えると思います。これは基底クラスの PointerInputModule で定義されている関数で中を見てみしょう。

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

namespace UnityEngine.EventSystems
{
    public abstract class PointerInputModule : BaseInputModule
    {
        // ...(略)
        
        protected virtual PointerEventData GetMousePointerEventData()
        {
            PointerEventData pointerData;
            var created = GetPointerData (kMouseId, out pointerData, true);

            pointerData.Reset ();

            if (created)
                pointerData.position = Input.mousePosition;

            Vector2 pos = Input.mousePosition;
            pointerData.delta = pos - pointerData.position;
            pointerData.position = pos;
            pointerData.scrollDelta = Input.mouseScrollDelta;

            eventSystem.RaycastAll (pointerData, m_RaycastResultCache);
            var raycast = FindFirstRaycast (m_RaycastResultCache);
            pointerData.pointerCurrentRaycast = raycast;
            m_RaycastResultCache.Clear ();
            return pointerData;
        }

        // ...(略)
    }
}

中を見てみると、なにやら eventSystem.RaycastAll() なるものが見えます。実は Canvas オブジェクトについていた Graphic Raycaster が、この Raycast を行っているコンポーネントで、Graphic Raycaster はコライダのついていない GUI のオブジェクトを判定するためのものです。

f:id:hecomi:20140925231630p:plain

つまりこいつが、マウスカーソルがどの GUI の上に乗っているか判断してくれているものになります。他にもコライダのついたオブジェクトに対して Physics Raycaster があったり、独自に Raycaster を作成することも出来ます。

まとめると、Raycaster を通じて得られたマウスカーソルが乗った(or タッチした等)オブジェクトに対して、Input Module が解釈したイベントを、イベントハンドラを通じて呼び出している形になっています。つまり、イベントを送信する対象をマウスカーソルでなく別の方法で決定したかったらカスタム Raycaster を、独自のルールでクリックやホバーなどを行いたかったらカスタム Input Module を作成する、ということを行えばよい形になります。

追記

次のエントリで図解しました。

VRInputModule を見てみる

概要が掴めたところで、先ほどの VRInputModule.cs を見てみます。

ここでは、カスタム Raycaster を作らずに、SetTargetObject() という static なメンバを通じてどのオブジェクトにイベントを送信するか、というのを決定しています。別途 InputManager というクラスで独自のボタン押下を検出し、SetTargetObject() を通じて指定された GameObject に対して Submit イベントを通知しています。また SetTargetObject() 内で、以前指定されていたオブジェクトは PointerExit イベントを、新しく指定されたオブジェクトに対しては PointerEnter イベントを送信することにより、フォーカス表現を再現しています。

元エントリにもある通りちょっと dirty な感じがしますが、実装はとても簡単なので良いですね。真面目にやるのであればカスタム Raycaster を作成すれば良いと思います。

おわりに

次回は、これを元に VR 用の GUI モジュールを作成してみます。