Unity 2DでRaycast/Colliderを使わずにマウスの位置からクリックされたオブジェクトを取得する

Unity 2Dで、UIなどに対してボタンを使わずにクリック判定を実装しようと思った場合に、ググると大体以下のような手順が記載されています。

  1. EventSystemを追加
  2. 対象のオブジェクトにBox Collider2Dなどのコンポーネントをアタッチ
  3. Box Collider2Dのサイズをオブジェクトに合わせる
  4. IPointerClickHandlerインターフェースを継承したコンポーネントを作成しアタッチ
  5. OnPointerClickメソッド内にクリックされたときの処理を記述

実際にはコード内に一連の処理を記述できるにしても、こうして一覧にすると結構面倒に思えます。

また、このようにColliderを使ってクリック判定を行うと、2Dの場合Canvasの設定によっては期待した動作をしてくれない時があるのが困ります。
例えばUniRxで、以下のようにOnMouseDownAsObservableでオブジェクトがクリックされた場合に名前を出力するとします。

        var objects = new List<GameObject>()
        {
            GameObject.Find("red"),
            GameObject.Find("blue"),
            GameObject.Find("green")
        };
        
        objects.ForEach(o =>
        {
            o.OnMouseDownAsObservable().Subscribe(_ =>
            {
                Debug.Log(o?.name);
            });
        });

このときヒエラルキー上ではblueオブジェクトの方が下にあり、gameビュー上でもそちらの方が前面にあるように見えますが、実際にはblueとredが重なっている部分をクリックしたときに出力されているのはredのほうで、blueの子であるgreenと重なっている場合に関しても同様です。Unityの2Dは3D空間上に存在するオブジェクトを平面に投影しているだけなのでこういった事が起こるものだと思われます。試しにredオブジェクトのZ座標を変更すると、同じように重なっている部分をクリックしてもblueが表示されたりします。

OnMouseDownAsObservable の実装がどうなっているか見てはいないのですが、例えばPhysics2D.Raycastからオブジェクトを取得しようとしても同じ結果になります。

    void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            var ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            var hit2d = Physics2D.Raycast(ray.origin, ray.direction);
            Debug.Log(hit2d.transform?.gameObject.name);
        }
    }

基本的には3D向けのプラットフォームであるはずのUnityを使っているのでまぁ仕方ない部分はあるとはいえ、2Dでゲーム作っているはずなのに本来存在しないz軸等を意識する必要があるのは面倒です(オブジェクトの表示順とか考慮する必要があるにせよ)。2Dであれば直感的にゲームビュー上で見えている最前面のオブジェクト、ヒエラルキー最下層のものを取得したいような気がします。
また根本的な問題?として、クリック判定を行いたいオブジェクト全てにColliderを設定しないといけないのが手間です。Collider2Dなどはオブジェクトのサイズに合わせて勝手にサイズを調整してくれたりはしないため、オブジェクトとColliderのサイズを合わせる場合、Updateメソッド内で逐一サイズを更新したりする必要があるのは、仮にコンポーネント化するにしても面倒です。2Dゲームでマウスのクリック判定するなら「レイキャストを飛ばしてヒットしたオブジェクトを取得する」というよりも、「マウスの位置(下)と同じ位置にあるオブジェクトを取得する」という処理のほうが一般的なように思えます。

なので今回そのような処理を実装してみました。コードは以下の通りです。

using System.Collections.Generic;
using System.Linq;
using UnityEngine;
using UnityEngine.SceneManagement;
using UnityEngine.UI;

namespace DefaultNamespace
{
    public class ObjectDetector : MonoBehaviour
    {
        public void Update()
        {
            if (Input.GetMouseButtonDown(0))
            {
                Debug.Log(GetForegroundObject()?.name);
            }
        }

        public GameObject GetForegroundObject()
        {
            var allGameObjects = new List<GameObject>();
            GameObject[] topObjects = SceneManager.GetActiveScene().GetRootGameObjects();
            topObjects.ToList().ForEach(
                gameObject =>
                {
                    gameObject.transform.GetComponentsInChildren<Transform>().ToList().ForEach(child =>
                    {
                        allGameObjects.Add(child.gameObject);
                    });
                });
                
            Vector2 mousePosition = Input.mousePosition;
            Vector3 worldPosition = Camera.main.ScreenToWorldPoint(new Vector3(mousePosition.x, mousePosition.y, 10f));
            Vector3[] worldCorners = new Vector3[4];

            var allGUIObjectsUnderCursor = allGameObjects
                .Where(gameObject =>
                {
                    var image = gameObject.GetComponent<Image>();
                    if (image == null) return false;
                    image.rectTransform.GetWorldCorners(worldCorners);
                    return worldCorners[0].x <= worldPosition.x && worldPosition.x <= worldCorners[2].x &&
                           worldCorners[0].y <= worldPosition.y && worldPosition.y <= worldCorners[2].y;
                })
                .ToList();

            return allGUIObjectsUnderCursor.LastOrDefault();
        }
    }
}

今回「ヒエラルキー上で下にあるオブジェクトを前面として扱う」方針のため、上のオブジェクトから深さ優先探索的にオブジェクトの並び順を考えます。

つまり、ヒエラルキー上で上のように表示されている場合、求める順番としてはこのようになります(数字が大きいほど前面にある)。

  1. red
  2. gray
  3. white
  4. black
  5. blue
  6. green

これに関しては、GetComponentInChildren がそのまま深さ優先探索となっているため、これを使えば望んだ通りの順番でオブジェクトの配列を取得する事ができます。

Component-GetComponentInChildren - Unity スクリプトリファレンス

GameObject や深さ優先探索を活用して、親子関係にある子オブジェクトから type のタイプのコンポーネントを取得します。

GetComponentInChildrenは親オブジェクトからの呼び出しになるため、SceneManager.GetActiveScene().GetRootGameObjects(); でシーン上で親を持たないオブジェクトを列挙しています。

「マウスの位置にあるオブジェクトを判定する」方法について、オブジェクトが何であるかによって範囲の取得方法が異なりますが、今回はRectTransformをもつオブジェクト、つまりuGUIのオブジェクトを対象としてます。 RectTransformの場合、GetWorldCornersメソッドがオブジェクトの4隅の座標を返すため、この範囲内にマウスカーソルが存在しているオブジェクトをリストに残し、残ったオブジェクトの最後のものがヒエラルキー上で最前面のものとして返しています。 RectTransform全てだとCanvasなども対象に含まれるため、ここではImageコンポーネントを持つものに限定しています。

RectTransform-GetWorldCorners - Unity スクリプトリファレンス

なおuGUIではなく、クリック判定するのをSpriteにしたい場合はWhere部分が以下のようになると思います。

        .Where(gameObject =>
        {
            var spriteRenderer = gameObject.GetComponent<SpriteRenderer>();
            if (spriteRenderer == null) return false;
            var worldCorners = GetSpriteCorners(spriteRenderer);
            return worldCorners[0].x <= worldPosition.x && worldPosition.x <= worldCorners[2].x &&
                    worldCorners[0].y <= worldPosition.y && worldPosition.y <= worldCorners[2].y;
        })
        ...
        public Vector3[] GetSpriteCorners(SpriteRenderer renderer)
        {
            Vector3 topRight = renderer.transform.TransformPoint(renderer.sprite.bounds.max);
            Vector3 topLeft = renderer.transform.TransformPoint(new Vector3(renderer.sprite.bounds.max.x, renderer.sprite.bounds.min.y, 0));
            Vector3 botLeft = renderer.transform.TransformPoint(renderer.sprite.bounds.min);
            Vector3 botRight = renderer.transform.TransformPoint(new Vector3(renderer.sprite.bounds.min.x, renderer.sprite.bounds.max.y, 0));
            return new Vector3[] { botLeft, topLeft, topRight, botRight };
        }

How do I get the positions of the corners of a sprite? - Unity Answers

ひとまずこれで、オブジェクトにColliderや別途コンポーネントを追加することなくクリックされたオブジェクトを取得することができました。 見てわかる通り実行のたびに全てのオブジェクトを取得しておりGetComponentも多用しているのでパフォーマンス悪いと思いますがご了承ください。

おわりに

これは記事を書いている最中にわかったことなんですが、Imageコンポーネントを持つUIオブジェクトなら別にEventSystem.current.RaycastAllでよかったです。 なのでuGUIが対象の場合は全てのオブジェクトから絞ったりするのではなくそちらを使ってください。Spriteの場合も何かしら良い方法があるかもしれないですが今回はそこまで調べてはいないです。。。

nopitech.com

motoseneet.com

あと余談ですが、マウス位置の判定部分はChatGPTで出力されたコードを一部そのまま使っています。 これは一般的な方法を確認する目的で質問してみた結果なのですが、そのまま使えるコードが出力されるのには驚きました。 質問内容を工夫すれば記事と同じ内容のコードも出力できるかもしれません。もう人間いらないかもしれない