UI Toolkit で Raycast を擬似的に表現する

# 概要

Unity でマウスやタッチした位置に描画された最前面のオブジェクトを取得したい場面がある。
具体的には、 Ray を飛ばして 3D オブジェクトを取得したりするのに使う。
この使い方の一つとして UI が描写されていれば背面に存在する 3D オブジェクトのマウスやタッチイベントを発火させない（インプットイベントを貫通させない）ようにすることができる。
UnityEngine.UI に属するゲームオブジェクトは Ray を工夫すれば貫通しない方法についてはインターネットにたくさんの方法が紹介されているが、 UI Toolkit(UnityEngine.UIElements) に属するゲームオブジェクトについてはその方法が特に日本語では紹介されていなかったため、本記事を寄稿することにした。
![貫通.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/982565/db1aa02f-db94-7300-c4ec-df57c2093248.png)

# 動作環境

UnityEngine 2023.1.0b13
Input System 1.5.1

# 結論

[参考文献1](https://www.reddit.com/r/Unity3D/comments/vyl3mr/ispointerovergameobject_equivalent_for/) の記事を翻訳してコードの一部をお借りしただけ。

# 課題

EventSystem の RaycastAll を用いて Ray にひっかかったものを出力した場合、 UnityEngine.UIElements (UI Toolkit) の Button を押しても、 Ray が Button を貫通し、背後の Cube が出力される。

![before.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/982565/44ee8da9-0144-4b44-991e-450b5776cdba.gif)

```c#
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UIElements;
using UnityEngine.InputSystem;

public class Sample : MonoBehaviour
{
    private PointerEventData pointData;

    private void Start()
    {
        // RaycastAllの引数PointerEvenDataを作成
        pointData = new PointerEventData(EventSystem.current);
    }

    private void Update()
    {
        if (Input.GetMouseButtonDown(0)) {
            string resultObjectName = "";

            // RaycastAllの結果格納用のリスト作成
            List<RaycastResult> RayResult = new List<RaycastResult>();

            // PointerEvenDataに、マウスの位置をセット
            pointData.position = Input.mousePosition;
            // RayCast（スクリーン座標）
            EventSystem.current.RaycastAll(pointData, RayResult);

            foreach (var rayResult in RayResult)
            {
                // UIDocument がヒエラルキーに配置されている場合 UIElements.PanelSettings がひっかかるのでスキップ
                if (rayResult.gameObject.GetComponent<PanelRaycaster>() != null) { continue; }

                // 最初に Raycast があたったオブジェクトを取得
                resultObjectName = rayResult.gameObject.name;
                break;
            }

            // Raycast に何かぶつかった場合は出力
            if (resultObjectName != "") {
                Debug.Log($"hit object: {resultObjectName}");
                return;
            }

            // 何もぶつからなかった場合は Raycast で出力
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            if (Physics.Raycast(ray.origin, ray.direction, out RaycastHit info, Mathf.Infinity))
            {
                Debug.Log($"hit object: {info.collider.name}");
            }
        }
    }
}
```

# 解決方法

そこで、[参考文献1](https://www.reddit.com/r/Unity3D/comments/vyl3mr/ispointerovergameobject_equivalent_for/) のコードの一部をお借りして以下のように UIToolkitRaycastChecker というクラスを定義して、 Awake のタイミングで UnityEngine.UIElements (UI Toolkit) の Button を引っ掛かるように定義して定義されたクラス内の IsHoverUI() というメソッドを実行することで問題を解消した。
なお、 PackageManager から InputSystem をダウンロードする必要がある。

![after.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/982565/7e97f767-8f60-4129-9974-b6effa3a5a71.gif)

```diff
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UIElements;
using UnityEngine.InputSystem;

public class Sample : MonoBehaviour
{
    private PointerEventData pointData;
+    [SerializeField] private UIDocument uiDocument;

+    private void Awake()
+    {
+        if (uiDocument == null) { return; }
+        VisualElement root = uiDocument.rootVisualElement;
+        Button button = root.Q<Button>();
+        // ボタンをヒット対象にする
+        UIToolkitRaycastChecker.RegisterBlockingElement(button);
+    }

    private void Start()
    {
        // RaycastAllの引数PointerEvenDataを作成
        pointData = new PointerEventData(EventSystem.current);
    }

    private void Update()
    {
        if (Input.GetMouseButtonDown(0)) {
            string resultObjectName = "";

            // RaycastAllの結果格納用のリスト作成
            List<RaycastResult> RayResult = new List<RaycastResult>();

            // PointerEvenDataに、マウスの位置をセット
            pointData.position = Input.mousePosition;
            // RayCast（スクリーン座標）
            EventSystem.current.RaycastAll(pointData, RayResult);
            
+            if (UIToolkitRaycastChecker.IsHoverUI())
+            {
+                Debug.Log($"hit object: {UIToolkitRaycastChecker.GetHitElement()}");
+                return;
+            }

            foreach (var rayResult in RayResult)
            {
                // UIDocument がヒエラルキーに配置されている場合 UIElements.PanelSettings がひっかかるのでスキップ
                if (rayResult.gameObject.GetComponent<PanelRaycaster>() != null) { continue; }

                // 最初に Raycast があたったオブジェクトを取得
                resultObjectName = rayResult.gameObject.name;
                break;
            }

            // Raycast に何かぶつかった場合は出力
            if (resultObjectName != "") {
                Debug.Log($"hit object: {resultObjectName}");
                return;
            }

            // 何もぶつからなかった場合は Raycast で出力
            Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition);
            if (Physics.Raycast(ray.origin, ray.direction, out RaycastHit info, Mathf.Infinity))
            {
                Debug.Log($"hit object: {info.collider.name}");
            }
        }
    }
}

+public static class UIToolkitRaycastChecker
+{
+    private static HashSet<VisualElement> _blockingElements = new HashSet<VisualElement>();
+
+    public static void RegisterBlockingElement(VisualElement blockingElement) =>
+        _blockingElements.Add(blockingElement);
+
+    public static bool IsBlockingRaycasts(VisualElement element)
+    {
+        return _blockingElements.Contains(element) &&
+               element.visible;
+    }
+
+    public static bool IsHoverUI()
+    {
+        foreach (var element in _blockingElements)
+        {
+            if (IsBlockingRaycasts(element) == false)
+                continue;
+
+            if (ContainsMouse(element) && element.visible)
+            {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
+    public static VisualElement GetHitElement()
+    {
+        foreach (var element in _blockingElements)
+        {
+            if (IsBlockingRaycasts(element) == false)
+                continue;
+
+            if (ContainsMouse(element) && element.visible)
+            {
+                return element;
+            }
+        }
+        return null;
+    }
+
+    private static bool ContainsMouse(VisualElement element)
+    {
+        // Nullぽケアで拡張
+        try
+        {
+            var mousePosition = Mouse.current.position.ReadValue();
+            var scaledMousePosition = new Vector2(mousePosition.x / Screen.width, mousePosition.y / Screen.height);
+
+            var flippedPosition = new Vector2(scaledMousePosition.x, 1 - scaledMousePosition.y);
+            // Null になることがある
+            var adjustedPosition = flippedPosition * element.panel.visualTree.layout.size;
+
+            var localPosition = element.WorldToLocal(adjustedPosition);
+
+            return element.ContainsPoint(localPosition);
+        }
+        catch
+        {
+            return false;
+        }
+    }
}
```

# 最後に
もっといい方法があればコメントください！
この記事で一人でも多くのエンジニアが手早くやりたいことを実現できますように🐳

# 参考文献
1. [IsPointerOverGameObject() Equivalent for UIToolkit/UI Elements　- reddit](https://www.reddit.com/r/Unity3D/comments/vyl3mr/ispointerovergameobject_equivalent_for/)