@natsu1211

ゲーム開発、プログラミング言語の話

uGUIの内部実装を完全理解する (4) - 入力処理のカスタマイズ

これまでの記事は3回を分けて、uGUIにおける入力とイベントの処理の流れを解説しました。 おさらいすると、

  1. ユーザーの入力はUnityのランタイムで処理され、関連情報はUnityEngine.Inputクラスに保存されます。uGUIではBaseInputクラスを介してUnityEngine.Inputから入力情報を取得します。
  2. EventSystemのUpdate関数は毎フレーム呼び出され、EventSystemに登録されたすべてのInputModule(通常はStandaloneInputModuleのみ)のUpdateModule関数と現在有効なInputModuleのProcess関数が呼び出されます。
  3. Process関数では、UnityEngine.Inputの情報に基づいて対応するイベントを生成し、EventSystem.RaycastAllを呼び出して押されたGameObjectを見つけ、イベントを送信します。
  4. 押されたGameObjectがイベントに対応するhandlerインターフェースを実装している場合、ハンドラー関数を呼び出してイベントに応答します。

この流れの中で、入力とイベント処理の動作をカスタマイズできるポイントがいくつかあります。

  • BaseInputを継承してカスタムInputクラスを実装する
    • ユーザー入力の処理方法をカスタマイズできます。これにより、独自の入力デバイスやダミー入力をサポートできます。
  • BaseInputModuleを継承してカスタムInputModuleを実装する
    • InputModuleの動作をカスタマイズできます。独自の入力処理ロジックやイベント生成の方法を実装できます。
  • BaseRaycasterを継承してカスタムRaycasterを実装する
    • クリックやタッチの処理方法を変更できます。
  • 入力イベントのハンドラーの中で独自のイベントを発行する。
    • 長押しやダブルクリックの発生を示す、より高度なイベントを発行できます。

BaseInputを継承してカスタムInputクラスを実装する

まず、BaseInputの定義は以下の通りです。各フィールドの意味は公式のドキュメントから確認できます。

public class BaseInput : UIBehaviour
{
    public virtual string compositionString
    {
        get { return Input.compositionString; }
    }

    public virtual IMECompositionMode imeCompositionMode
    {
        get { return Input.imeCompositionMode; }
        set { Input.imeCompositionMode = value; }
    }

    public virtual Vector2 compositionCursorPos
    {
        get { return Input.compositionCursorPos; }
        set { Input.compositionCursorPos = value; }
    }

    public virtual bool mousePresent
    {
        get { return Input.mousePresent; }
    }

    public virtual bool GetMouseButtonDown(int button)
    {
        return Input.GetMouseButtonDown(button);
    }

    public virtual bool GetMouseButtonUp(int button)
    {
        return Input.GetMouseButtonUp(button);
    }

    public virtual bool GetMouseButton(int button)
    {
        return Input.GetMouseButton(button);
    }

    public virtual Vector2 mousePosition
    {
        get { return Input.mousePosition; }
    }

    public virtual Vector2 mouseScrollDelta
    {
        get { return Input.mouseScrollDelta; }
    }

    public virtual bool touchSupported
    {
        get { return Input.touchSupported; }
    }

    public virtual int touchCount
    {
        get { return Input.touchCount; }
    }

    public virtual Touch GetTouch(int index)
    {
        return Input.GetTouch(index);
    }

    public virtual float GetAxisRaw(string axisName)
    {
        return Input.GetAxisRaw(axisName);
    }

    public virtual bool GetButtonDown(string buttonName)
    {
        return Input.GetButtonDown(buttonName);
    }
}

BaseInputのデフォルト実装は、Inputから入力情報をそのまま取得するだけですが、重要なのはBaseInputは入力を取得する動作をオーバーライドできる抽象レイヤーを提供したことです。

つまり、下の例のように、BaseInputを継承して独自なInputクラスを定義できます。

public class CustomInput : BaseInput
{
    // マウスのX座標をオーバーライド
    public override float GetMouseX()
    {
        // マウスのX座標を2倍にする
        return base.GetMouseX() * 2;
    }

    // マウスのY座標をオーバーライド
    public override float GetMouseY()
    {
        // マウスのY座標を2倍にする
        return base.GetMouseY() * 2;
    }

    // キーボード入力をオーバーライド
    public override bool GetKeyDown(KeyCode key)
    {
        // 特定のキー(ここではAキー)の入力を常にfalseにする
        if (key == KeyCode.A)
        {
            return false;
        }
        return base.GetKeyDown(key);
    }
}

この例はそこまでの実用性はないですが、 下の例のように独自のデバイスからの入力を取得する、特定の入力にダミー入力を使用するなど、用途は多岐に渡る。

public class CustomInput1 : BaseInput
{
    // 例: 独自の入力デバイスから軸の値を取得する
    public override float GetAxis(string axisName)
    {
        // 独自の軸取得ロジック
        if (axisName == "Horizontal")
        {
            // カスタムデバイスの水平軸の値を返す
            return CustomDevice.GetHorizontalAxis();
        }
        else if (axisName == "Vertical")
        {
            // カスタムデバイスの垂直軸の値を返す
            return CustomDevice.GetVerticalAxis();
        }
        return base.GetAxis(axisName);
    }
}

public class CustomInput2 : BaseInput
{
    // ダミー入力
    public override Vector2 mousePosition => new Vector2(100, 100);
}

CustomInputを使用するには、InputModuleのm_InputOverrideフィールドを設定する必要があります。 前回の記事でも説明したように、m_InputOverrideがnullでない場合、BaseInputより優先的に使われます。

public class CustomInputModule : StandaloneInputModule
{
    protected override void Awake()
    {
        base.Awake();
        m_InputOverride = new CustomInput();
    }
}

最後はEventSystemにアタッチされているStandaloneInputModuleコンポネントをCustomInputModuleに差し替えればOKです。

BaseInputModuleを継承してカスタムのInputModuleを実装する

カスタムのInputModuleを作成することで、ほぼあらゆる機能を実現できますが、将来的なuGUIのアップグレードとの互換性がなくなる可能性があります。また、Unityの新しいInputSystemは独自のInputModule(InputSystemUIInputModule)を実装しており、カスタムInputModuleは新InputSystemの導入の妨げになる可能性があります。そのため、必要がない限り、この記事で紹介した他の方法でニーズが満たされるかを検討することをお勧めします。

次の例では、ダブルクリックイベントを送信できるカスタムInputModuleを定義します。 まずPointerDoubleClickイベントとイベント処理インターフェースIPointerDoubleClickHandlerを定義する必要があります。

// ExecuteEvents.cs
private static readonly EventFunction<IPointerDoubleClickHandler> s_PointerDoubleClickHandler = Execute;

private static void Execute(IPointerDoubleClickHandler handler, BaseEventData eventData)
{
    handler.OnPointerDoubleClick(ValidateEventData<PointerEventData>(eventData));
}

public static EventFunction<IPointerDoubleClickHandler> pointerDoubleClickHandler
{
    get { return s_PointerDoubleClickHandler; }
}

次にStandaloneInputModuleの内容をコピーしてNewInputModuleを実装します。

このクラスはStandaloneInputModuleとほぼ同じように機能しますが、ProcessTouchPressメソッドの実装だけ変更していて、選択されたオブジェクトにPointerDoubleClickイベントを送信することができます。

public class NewInputModule : PointerInputModule
{
    
    protected void ProcessTouchPress(PointerEventData pointerEvent, bool pressed, bool released)
    {
        // 省略

        if (released)
        {
            // Debug.Log("Executing pressup on: " + pointer.pointerPress);
            ExecuteEvents.Execute(pointerEvent.pointerPress, pointerEvent, ExecuteEvents.pointerUpHandler);

            // see if we mouse up on the same element that we clicked on...
            var pointerClickHandler = ExecuteEvents.GetEventHandler<IPointerClickHandler>(currentOverGo);
            var pointerDoubleClickHandler = ExecuteEvents.GetEventHandler<IPointerDoubleClickHandler>(currentOverGo);

            // PointerClick and Drop events
            if (pointerEvent.pointerClick == pointerClickHandler && pointerEvent.eligibleForClick)
            {
                ExecuteEvents.Execute(pointerEvent.pointerClick, pointerEvent, ExecuteEvents.pointerClickHandler);
            }

            // ダブルクリックイベント
            if (pointerEvent.pointerClick == pointerDoubleClickHandler && pointerEvent.eligibleForClick)
            {
                var deltaTime = Time.unscaledTime - pointerEvent.clickTime;
                if (deltaTime > 0.6)
                {
                    ExecuteEvents.Execute(pointerEvent.pointerClick, pointerEvent, ExecuteEvents.pointerDoubleClickHandler);
                }
            }
        }
    }
}

UIコンポネントがIPointerDoubleClickHandlerインタフェースを実装していれば、PointerDoubleClickイベントを処理することができます。

BaseRaycasterを継承してカスタムのRaycasterを実装する

通常、Unityが提供するGraphicRaycaster、PhysicsRaycaster、Physics2DRaycasterは十分にニーズを満たせますが、必要に応じてBaseRaycasterを継承して新しいRaycasterをカスタマイズすることができます。Raycast関数をオーバーライドすることで、カスタムのレイキャスト動作を実装できます。ここでは具体的な例を割愛します。

UIToolkitではPanelRaycasterが提供されており、新しいInputSystemではTrackedDeviceRaycasterも提供されています。それらの実装を参考できます。

入力イベントに応答し独自のイベントを提供する

これは多分一番よく使う方法で、カスタムスクリプトを作成してInputModuleから送信されたイベントに応答し、イベントハンドラーの中で独自のイベントを発行します。

例えば、UIオブジェクトを長押しした場合にOnLongPressイベント、ダブルクリックした場合にOnDoubleClickイベントを発行するTouchBehaviourスクリプトを作成します。

using UnityEngine;
using UnityEngine.Events;
using UnityEngine.EventSystems;


public class TouchBehaviour : MonoBehaviour, IPointerDownHandler, IPointerUpHandler, IPointerExitHandler, IPointerClickHandler
{
    private bool _isPressed = false;
    private bool _isLongPressed = false;
    private float _pressedTime = 0.0f;
    private float _previousClickTime = 0.0f;

    //長押しの最低間隔
    public float LongPressInterval { get; } = 0.6f;

    //ダブルクリックの有効間隔
    public float DoubleClickInterval { get; } = 0.2f;

    public UnityEvent OnLongPress = new();
    public UnityEvent OnDoubleClick = new();

    // タッチ/クリックされたときに呼ばれる
    public void OnPointerDown(PointerEventData eventData)
    {
        _isPressed = true;
    }

    // タッチ/クリックが離れたときに呼ばれる
    public void OnPointerUp(PointerEventData eventData)
    {
        if (_isLongPressed)
        {
            OnLongPress.Invoke();
        }
        _isPressed = false;
        _isLongPressed = false;
        _pressedTime = 0.0f;
    }

    // タッチ/クリックがUIオブジェクトの範囲から離れたときに呼ばれる(押したまま離れた場合でも呼ばれる)
    public void OnPointerExit(PointerEventData eventData)
    {
        // 押したままオブジェクトから離れた場合はタッチ、長押しを無効にする
        _isPressed = false;
        _isLongPressed = false;
        _pressedTime = 0.0f;
    }

    // 同じUIオブジェクトが押されて、そして離れたときに呼ばれる(ただ、途中で一度オブジェクトから離れたかどうかは検知していません)
    public void OnPointerClick(PointerEventData eventData)
    {
        // 前回のクリックから一定時間以内にクリックされた場合はダブルクリックとして扱う
        if (_previousClickTime != 0 && Time.unscaledTime - _previousClickTime <= DoubleClickInterval)
        {
            OnDoubleClick.Invoke();
        }
        _previousClickTime = Time.unscaledTime;
    }

    void Start()
    {
        // 実際の使用では別のスクリプトでハンドラーを登録する
        // UniRxでラップした後にObserverを登録できるようにするなど、お好きなようにどうぞ
        OnLongPress.AddListener(() => Debug.Log("Long Pressed"));
        OnDoubleClick.AddListener(() => Debug.Log("Double Clicked"));
    }

    void Update()
    {
        if (_isPressed && !_isLongPressed)
        {
            _pressedTime += Time.deltaTime;
            if (_pressedTime >= LongPressInterval)
            {
                _isLongPressed = true;
            }
        }
    }
}

もちろん、TouchBehaviourを使うには、UIコンポネントと同じオブジェクトにアタッチする必要があります。

試しにTouchBehaviourスクリプトをシーン内のImageにアタッチします。Imageを0.6秒以上長押しすると、Long Pressedのログが出力されることが確認できます。そして、Imageの範囲内に素早くダブルクリックするとDouble Clickedのログが出力されます。

また、uGUIはEventTriggerも提供しています。これの原理は上記の例と同じですが、事前定義されていたすべてのUIイベントに応答でき、さらにInspectorで直接ハンドラーを追加することができます。

public class EventTrigger :
    MonoBehaviour,
    IPointerEnterHandler,
    IPointerExitHandler,
    IPointerDownHandler,
    IPointerUpHandler,
    IPointerClickHandler,
    IInitializePotentialDragHandler,
    IBeginDragHandler,
    IDragHandler,
    IEndDragHandler,
    IDropHandler,
    IScrollHandler,
    IUpdateSelectedHandler,
    ISelectHandler,
    IDeselectHandler,
    IMoveHandler,
    ISubmitHandler,
    ICancelHandler
{
    [Serializable]
    /// <summary>
    /// UnityEvent class for Triggers.
    /// </summary>
    public class TriggerEvent : UnityEvent<BaseEventData>
    {}

    [Serializable]
    /// <summary>
    /// An Entry in the EventSystem delegates list.
    /// </summary>
    /// <remarks>
    /// It stores the callback and which event type should this callback be fired.
    /// </remarks>
    public class Entry
    {
        /// <summary>
        /// What type of event is the associated callback listening for.
        /// </summary>
        public EventTriggerType eventID = EventTriggerType.PointerClick;

        /// <summary>
        /// The desired TriggerEvent to be Invoked.
        /// </summary>
        public TriggerEvent callback = new TriggerEvent();
    }

    [FormerlySerializedAs("delegates")]
    [SerializeField]
    private List<Entry> m_Delegates;

    [System.ComponentModel.EditorBrowsable(System.ComponentModel.EditorBrowsableState.Never)]
    [Obsolete("Please use triggers instead (UnityUpgradable) -> triggers", true)]
    public List<Entry> delegates { get { return triggers; } set { triggers = value; } }

    protected EventTrigger()
    {}

    /// <summary>
    /// All the functions registered in this EventTrigger
    /// </summary>
    public List<Entry> triggers
    {
        get
        {
            if (m_Delegates == null)
                m_Delegates = new List<Entry>();
            return m_Delegates;
        }
        set { m_Delegates = value; }
    }

    private void Execute(EventTriggerType id, BaseEventData eventData)
    {
        for (int i = 0; i < triggers.Count; ++i)
        {
            var ent = triggers[i];
            if (ent.eventID == id && ent.callback != null)
                ent.callback.Invoke(eventData);
        }
    }

    /// <summary>
    /// Called by the EventSystem when the pointer enters the object associated with this EventTrigger.
    /// </summary>
    public virtual void OnPointerEnter(PointerEventData eventData)
    {
        Execute(EventTriggerType.PointerEnter, eventData);
    }

    /// <summary>
    /// Called by the EventSystem when the pointer exits the object associated with this EventTrigger.
    /// </summary>
    public virtual void OnPointerExit(PointerEventData eventData)
    {
        Execute(EventTriggerType.PointerExit, eventData);
    }

    /// <summary>
    /// Called by the EventSystem every time the pointer is moved during dragging.
    /// </summary>
    public virtual void OnDrag(PointerEventData eventData)
    {
        Execute(EventTriggerType.Drag, eventData);
    }

    /// <summary>
    /// Called by the EventSystem when an object accepts a drop.
    /// </summary>
    public virtual void OnDrop(PointerEventData eventData)
    {
        Execute(EventTriggerType.Drop, eventData);
    }

    /// <summary>
    /// Called by the EventSystem when a PointerDown event occurs.
    /// </summary>
    public virtual void OnPointerDown(PointerEventData eventData)
    {
        Execute(EventTriggerType.PointerDown, eventData);
    }

    /// <summary>
    /// Called by the EventSystem when a PointerUp event occurs.
    /// </summary>
    public virtual void OnPointerUp(PointerEventData eventData)
    {
        Execute(EventTriggerType.PointerUp, eventData);
    }

    /// <summary>
    /// Called by the EventSystem when a Click event occurs.
    /// </summary>
    public virtual void OnPointerClick(PointerEventData eventData)
    {
        Execute(EventTriggerType.PointerClick, eventData);
    }

    /// <summary>
    /// Called by the EventSystem when a Select event occurs.
    /// </summary>
    public virtual void OnSelect(BaseEventData eventData)
    {
        Execute(EventTriggerType.Select, eventData);
    }

    /// <summary>
    /// Called by the EventSystem when a new object is being selected.
    /// </summary>
    public virtual void OnDeselect(BaseEventData eventData)
    {
        Execute(EventTriggerType.Deselect, eventData);
    }

    /// <summary>
    /// Called by the EventSystem when a new Scroll event occurs.
    /// </summary>
    public virtual void OnScroll(PointerEventData eventData)
    {
        Execute(EventTriggerType.Scroll, eventData);
    }

    /// <summary>
    /// Called by the EventSystem when a Move event occurs.
    /// </summary>
    public virtual void OnMove(AxisEventData eventData)
    {
        Execute(EventTriggerType.Move, eventData);
    }

    /// <summary>
    /// Called by the EventSystem when the object associated with this EventTrigger is updated.
    /// </summary>
    public virtual void OnUpdateSelected(BaseEventData eventData)
    {
        Execute(EventTriggerType.UpdateSelected, eventData);
    }

    /// <summary>
    /// Called by the EventSystem when a drag has been found, but before it is valid to begin the drag.
    /// </summary>
    public virtual void OnInitializePotentialDrag(PointerEventData eventData)
    {
        Execute(EventTriggerType.InitializePotentialDrag, eventData);
    }

    /// <summary>
    /// Called before a drag is started.
    /// </summary>
    public virtual void OnBeginDrag(PointerEventData eventData)
    {
        Execute(EventTriggerType.BeginDrag, eventData);
    }

    /// <summary>
    /// Called by the EventSystem once dragging ends.
    /// </summary>
    public virtual void OnEndDrag(PointerEventData eventData)
    {
        Execute(EventTriggerType.EndDrag, eventData);
    }

    /// <summary>
    /// Called by the EventSystem when a Submit event occurs.
    /// </summary>
    public virtual void OnSubmit(BaseEventData eventData)
    {
        Execute(EventTriggerType.Submit, eventData);
    }

    /// <summary>
    /// Called by the EventSystem when a Cancel event occurs.
    /// </summary>
    public virtual void OnCancel(BaseEventData eventData)
    {
        Execute(EventTriggerType.Cancel, eventData);
    }
}

すべてのハンドラーはList<Entry> delegatesに保存されていることがわかります。各イベントのハンドラーでは、delegatesをなめて、EventTriggerTypeが一致するハンドラーを見つけて呼び出します。また、EventTriggerを継承して、イベント応答関数をオーバーライドすることもできます。

ただ、Inspectorで直接イベントハンドラーを追加することは殆どないし、手動でtriggersにハンドラーを追加するのも少し面倒です。そのため、実際のプロジェクトではEventTriggerを直接使用することは殆どなく、自前でTouchBehaviourのようなスクリプトを自作したほうが柔軟だと思います。

どの方法を取るべきか

どの方法で入力処理をカスタマイズするかは実現したい機能によりますが、いくつかのアドバイスがあります。

  • UnityEngine.Input以外から入力情報を取得(あるいはダミーの入力を提供)したい場合、BaseInputを継承してCustomInputを実装すべき。
    • UIの自動テストなどで役立ちます。
    • UIに限らず、ゲームプレイでも利用するなら、新InputSystemの使用も検討すべき。
  • Pointer系イベントを独自のイベントに変換したいだけなら、TouchBehaviourのようなスクリプトを用意して問題ありません。
    • 頻繁に利用する場合(例えばデフォルトでButtonに長押しイベントを発行できるようにしたい)、UIコンポネントを独自実装して、TouchBehaviourの機能をUIコンポネントの一部として実装するのもあり。  
    • EventTriggerを使うのもいいですが、汎用性のためにやや柔軟性を犠牲にしているため、自前で用意したほうが柔軟。
  • 直接UnityEngine.Inputから入力情報を取得やRaycastを行いたい場合、独自のスクリプトでやるより、カスタムInputModuleの実装を考えるべき。
    • 独自のスクリプトでやると、InputModuleの役割と重複する。
    • デフォルトのStandaloneInputModuleはすでに全入力に対するRaycastの結果を持っているため、パフォーマンス面で考えても再度Raycastをやるべきではありません。StandaloneInputModuleをベースに改造したほうがパフォーマンスがいい。

ここまででuGUIにおける入力とイベントの処理を一通り解説しました。この部分を理解できれば、uGUIの大半を理解したと言っても過言ではありません。より自信をもってuGUIのカスタマイズをできるようになると思います。

結局ソースリーディング自体が目的ではなく、原理を理解した上でより高機能のUIを作り、ゲームの表現力の向上に寄与することこそが目的です。

次回からはUIコンポネントの描画部分の解説に移ります。ユーザーが直接使う部分ではないですが、uGUIのUIコンポネントの表示はどう更新されるのか、どこがパフォーマンスのネックになりやすいのかを理解できるようになります。 ではまた次回で。