射线检测的本质就是,在屏幕上的点击位置发出一条射线,按照一定规则,筛选被射线命中的物体,最后在排序拿到第一个物体,然后进行各种操作

大致涉及一下类:

  • Ray: 射线类
  • RaycastResult: 投射结果
  • RaycasterManager: 投射器管理器
  • BaseRaycaster: 射线投射基类
  • GraphicRaycaster : BaseRaycaster: 图形投射器
  • PhysicsRaycaster : BaseRaycaster: 针对3D物体的投射器, 需要对象上同时存在Camera组件
  • Physics2DRaycaster : PhysicsRaycaster: 针对2D物体的投射器, 需要对象上同时存在Camera组件

Ray

射线,创建射线的方式有很多种,UGUI的射线检测肯定是基于UICamera的

一般有两种方法,都是以相机位置为端点发射射线

目标点可以是近平面上的点,比如Input.mousePosition

var ray = Camera.main.ScreenPointToRay(Vector3 screenPos)

也可以是视口坐标(0-1)

var ray = Camera.main.ViewportPointToRay(Vector3 viewPortPos)

RaycastResult

只是一个数据类,里面包含了很多射线检测的具体数据

RaycastManager

一个静态类,没什么逻辑,会管理所有需要射线检测的物体

internal static class RaycasterManager
{
    private static readonly List<BaseRaycaster> s_Raycasters = new List<BaseRaycaster>();

    public static void AddRaycaster(BaseRaycaster baseRaycaster)
    {
        if (s_Raycasters.Contains(baseRaycaster))
            return;

        s_Raycasters.Add(baseRaycaster);
    }

    public static List<BaseRaycaster> GetRaycasters()
    {
        return s_Raycasters;
    }

    public static void RemoveRaycasters(BaseRaycaster baseRaycaster)
    {
        if (!s_Raycasters.Contains(baseRaycaster))
            return;
        s_Raycasters.Remove(baseRaycaster);
    }
}

其中BaseRaycaster是一个基类,底层是一个Mono,需要射线检测的对象,需要继承实现它,然后在生命周期内(OnEnable/OnDisable),注册/取消注册,到RaycastManager里

BaseRaycaster

是一个抽象类,需要继承实现

public abstract class BaseRaycaster : UIBehaviour
{}

public abstract class UIBehaviour : MonoBehaviour
{}

如上面所说,会在生命周期内注册到Manager里

protected override void OnEnable()
{
    base.OnEnable();
    RaycasterManager.AddRaycaster(this);
}

protected override void OnDisable()
{
    RaycasterManager.RemoveRaycasters(this);
    base.OnDisable();
}

另一个关键方法,需要子类自己实现,其中eventData就是UGUI里传递的数据,需要根据数据,自己去填充结果数组

/// <summary>
/// Raycast against the scene.
/// </summary>
/// <param name="eventData">Current event data.</param>
/// <param name="resultAppendList">List of hit Objects.</param>
public abstract void Raycast(PointerEventData eventData, List<RaycastResult> resultAppendList);

GraphicRaycaster

[RequireComponent(typeof(Canvas))]
public class GraphicRaycaster : BaseRaycaster
{}

UGUI的射线检测类,需要Canvas,所以一般就直接挂在Canvas上

image-20231007194017214

所以并不是每一个UGUI元素都会被单独射线检测,整体的检测逻辑是依赖Canvas进行的

  • ignoreReversedGraphics 忽略图形背面,通过点乘判断方向,如果是从图形背面传过的射线,会被忽略
  • BlockingObjects 物体阻挡,如果点击到对应的物体,则射线不会向下传递,这里的物体不是指UGUI,而是指碰撞盒

接下来才是核心,即Canvas如何收集被点击到的物体,重写了Raycast方法

public override void Raycast(PointerEventData eventData, List<RaycastResult> resultAppendList)
{
	// 拿到Canvas下所有可以被射线检测的Child
    var canvasGraphics = GraphicRegistry.GetRaycastableGraphicsForCanvas(canvas);

    int displayIndex;
    var currentEventCamera = eventCamera;
	// 如果canvas的模式是overlay,那么使用canvas的指定屏幕
    // 否则就使用canvas上挂载的相机的置顶屏幕
    if (canvas.renderMode == RenderMode.ScreenSpaceOverlay || currentEventCamera == null)
        displayIndex = canvas.targetDisplay;
    else
        displayIndex = currentEventCamera.targetDisplay;

    // 计算点击位置在多屏幕里的位置,不考虑这里了
    var eventPosition = Display.RelativeMouseAt(eventData.position);
    ...

    // 转换到视口坐标
    Vector2 pos;
    if (currentEventCamera == null)
    {
        // 这里只可能是多显示器的情况,忽略
        ...
    }
    else
    {
        pos = currentEventCamera.ScreenToViewportPoint(eventPosition);        
    }

    
    if (pos.x < 0f || pos.x > 1f || pos.y < 0f || pos.y > 1f)
        return;

    float hitDistance = float.MaxValue;

    // 使用屏幕坐标进行射线检测
    Ray ray = new Ray();
    if (currentEventCamera != null)
        ray = currentEventCamera.ScreenPointToRay(eventPosition);

    // 这里是ColliderBox物体阻挡,不考虑了
    if (canvas.renderMode != RenderMode.ScreenSpaceOverlay && blockingObjects != BlockingObjects.None)
    {
        ...
    }

    m_RaycastResults.Clear();
	// 真正的射线检测,原理就是用Rect框判断的,代码放在下面
    Raycast(canvas, currentEventCamera, eventPosition, canvasGraphics, m_RaycastResults);

    int totalCount = m_RaycastResults.Count;
    for (var index = 0; index < totalCount; index++)
    {
        var go = m_RaycastResults[index].gameObject;
        bool appendGraphic = true;

        // 背面检测
        if (ignoreReversedGraphics)
        {
            ...
        }

        // 最后计算各种信息
        if (appendGraphic)
        {
            float distance = 0;
            Transform trans = go.transform;
            Vector3 transForward = trans.forward;

            if (currentEventCamera == null || canvas.renderMode == RenderMode.ScreenSpaceOverlay)
                distance = 0;
            else
            {
                // http://geomalgorithms.com/a06-_intersect-2.html
                distance = (Vector3.Dot(transForward, trans.position - ray.origin) /
                            Vector3.Dot(transForward, ray.direction));

                // Check to see if the go is behind the camera.
                if (distance < 0)
                    continue;
            }

            if (distance >= hitDistance)
                continue;

            var castResult = new RaycastResult
            {
                gameObject = go,
                module = this,
                distance = distance,
                screenPosition = eventPosition,
                displayIndex = displayIndex,
                index = resultAppendList.Count,
                depth = m_RaycastResults[index].depth,
                sortingLayer = canvas.sortingLayerID,
                sortingOrder = canvas.sortingOrder,
                worldPosition = ray.origin + ray.direction * distance,
                worldNormal = -transForward
            };
            resultAppendList.Add(castResult);
        }
    }
}

然后是判断是否点击到的具体方法

private static void Raycast(Canvas canvas, Camera eventCamera, Vector2 pointerPosition, IList<Graphic> foundGraphics, List<Graphic> results)
{
    int totalCount = foundGraphics.Count;
    for (int i = 0; i < totalCount; ++i)
    {
        Graphic graphic = foundGraphics[i];

        // 是否可以点击
        if (!graphic.raycastTarget || graphic.canvasRenderer.cull || graphic.depth == -1)
            continue;

        // 是否在Rect范围内
        if (!RectTransformUtility.RectangleContainsScreenPoint(graphic.rectTransform, pointerPosition, eventCamera, graphic.raycastPadding))
            continue;

        // 是否超过相机原裁面
        if (eventCamera != null && eventCamera.WorldToScreenPoint(graphic.rectTransform.position).z > eventCamera.farClipPlane)
            continue;

        // graphic的Raycast方法
        if (graphic.Raycast(pointerPosition, eventCamera))
        {
            s_SortedGraphics.Add(graphic);
        }
    }

    s_SortedGraphics.Sort((g1, g2) => g2.depth.CompareTo(g1.depth));
    totalCount = s_SortedGraphics.Count;
    for (int i = 0; i < totalCount; ++i)
        results.Add(s_SortedGraphics[i]);

    s_SortedGraphics.Clear();
}

在看Graphic上的RayCast方法,大多数UGUI组件都会继承Graphic,这个方法用于判断自身是否真的被点击到

public virtual bool Raycast(Vector2 sp, Camera eventCamera)
{
    ...
	// 从自身开始
    var t = transform;
    var components = ListPool<Component>.Get();

    bool ignoreParentGroups = false;
    bool continueTraversal = true;

    while (t != null)
    {
        // 拿到所有组件,遍历
        t.GetComponents(components);
        for (var i = 0; i < components.Count; i++)
        {
            // 如果有canvas组件并且override了,那么不会继续往上递归了
            var canvas = components[i] as Canvas;
            if (canvas != null && canvas.overrideSorting)
                continueTraversal = false;

            // UGUI会实现这个接口
            var filter = components[i] as ICanvasRaycastFilter;

            if (filter == null)
                continue;

            var raycastValid = true;

            var group = components[i] as CanvasGroup;
            if (group != null)
            {
                ...
            }
            else
            {
                raycastValid = filter.IsRaycastLocationValid(sp, eventCamera);
            }

            if (!raycastValid)
            {
                ListPool<Component>.Release(components);
                return false;
            }
        }
        // 会一直向上递归,直到NULL或者Canvas,所以合法性检测是以Canvas为基的
        t = continueTraversal ? t.parent : null;
    }
    ListPool<Component>.Release(components);
    return true;
}