跳转至

在 UI 上实现目标追踪

实现目标任务追踪功能。在 UI 上的椭圆区域内,显示目标的方向和距离。

演示
演示

配置椭圆

公开两个字段,用于配置椭圆两个轴的长度。取值范围设置为 \([0,1]\),方便配置。另外,规定水平的是长轴,竖直的是短轴。

[Range(0, 1)] public float HorizontalSize = 0.8f;
[Range(0, 1)] public float VerticalSize = 0.8f;

结合 Panel 的 RectTransform.rect 可以确定椭圆方程中的 \(a\)\(b\)

1
2
3
4
5
6
private void GetEllipseParam(RectTransform panelRect, out float a, out float b)
{
    Rect rect = panelRect.rect;
    a = rect.width * 0.5f * HorizontalSize;
    b = rect.height * 0.5f * VerticalSize;
}

编辑器辅助线

在编辑器中绘制辅助线,帮助我们直观地看到椭圆范围。

辅助线
辅助线

Unity 没有提供绘制椭圆的方法。只能在椭圆上多采一些离散的点,然后用 Gizmos.DrawLineStrip(points, true) 绘制,第二个参数为 true 表示绘制为首尾闭合的多边形。

private void OnDrawGizmosSelected()
{
    RectTransform rect = transform as RectTransform;
    GetEllipseParam(rect, out float a, out float b);
    Vector3[] points = new Vector3[360];

    // 极坐标下,每隔一度采一个点
    for (int i = 0; i < 360; i++)
    {
        float x = a * Mathf.Cos(i * Mathf.Deg2Rad);
        float y = b * Mathf.Sin(i * Mathf.Deg2Rad);
        points[i] = rect.TransformPoint(x, y, 0);
    }

#if UNITY_EDITOR
    UnityEditor.Handles.Label(points[270], "跟踪图标的运动范围");
#endif

    var color = Gizmos.color;
    Gizmos.color = Color.green;
    Gizmos.DrawLineStrip(new ReadOnlySpan<Vector3>(points), true);
    Gizmos.color = color;
}

计算目标点在 UI 上的位置

先计算目标点的屏幕坐标,再转换成 Panel 的本地坐标即可。考虑两种特殊情况:

  1. 目标点在屏幕后面。

    判断方法很多,用屏幕坐标的深度、相机空间坐标的 z 值都可以。

  2. 目标点不在椭圆范围内。

    设目标点在 Panel 中的本地坐标为 \((x,y)\),如果 \(\dfrac{x^2}{a^2}+\dfrac{y^2}{b^2}>1\) 就是在外面。

这时要把坐标限制在椭圆上。根据目标点在相机空间的坐标 posVS.xy 可以确定它在 Panel 本地坐标系中相对于原点的方向,进而确定一条从原点出发的射线。记 posVS.xy\((u,v)\),该射线与椭圆的交点为:

\[ \frac{ab}{\sqrt{a^2v^2+b^2u^2}} (u,v) \]
  • 使用这个公式时,没必要对 \((u,v)\) 归一化。
  • 不能用屏幕坐标代替 posVS.xy!!!屏幕坐标在超出屏幕范围后容易跳变,显示出来效果不够丝滑。
private Vector2 GetLocalPos(Transform target, RectTransform panelRect, Camera mainCamera, Camera uiCamera, out bool isOutsideView)
{
    GetEllipseParam(panelRect, out float a, out float b);
    Vector3 posVS = mainCamera.worldToCameraMatrix.MultiplyPoint3x4(target.position);
    Vector2 localPos;

    if (posVS.z >= 0)
    {
        isOutsideView = true;
        localPos = Vector2.zero;
    }
    else
    {
        Vector3 posNDC = mainCamera.projectionMatrix.MultiplyPoint(posVS);
        Vector2 posScreen = new Vector2((posNDC.x + 1) * 0.5f * Screen.width, (posNDC.y + 1) * 0.5f * Screen.height);
        RectTransformUtility.ScreenPointToLocalPointInRectangle(panelRect, posScreen, uiCamera, out localPos);
        isOutsideView = (localPos.x * localPos.x) / (a * a) + (localPos.y * localPos.y) / (b * b) > 1;
    }

    if (isOutsideView)
    {
        float s = a * b / Mathf.Sqrt(a * a * posVS.y * posVS.y + b * b * posVS.x * posVS.x);
        (localPos.x, localPos.y) = (posVS.x * s, posVS.y * s);
    }

    return localPos;
}

MultiplyPoint 相比 MultiplyPoint3x4 多了透视除法,所以乘出来直接是 NDC 坐标。具体可以看 MultiplyPoint 的源码。

把结果赋值给 Panel 里一个图标的 localPosition 即可。箭头方向、距离之类的功能比较简单,不写了。