Unity开发:两种屏幕外目标点标记的实现方法

作者:Quin7et
2023-01-31
18 14 0

前言

近期在做个人项目的时候,需要实现一个提示目标点位置的标记 UI。本以为是个相对简单的任务,但研究后发现还是有不少隐藏路障。本文将介绍两种不同的屏幕外目标点标记的实现方式,分别对应《守望先锋》及大部分第一人称游戏。

项目演示链接:https://github.com/Quin7et/OffScreenObjectiveMarker

屏幕内标记

在 Unity 中,要将 UI 摆放在屏幕内标记的位置十分简单,用 Unity 相机自带的 WorldToScreenPoint()方法即可。一个非常简易的实现如下:

public class ObjectiveMarker : MonoBehaviour
{
    public Transform TargetTransform;
    public Image img;

    private void LateUpdate()
    {
        img.transform.position = Camera.main.WorldToScreenPoint(TargetTransform.position);
    }
}

效果如图:

红色球为目标点,绿色方块为标记

这里的 Canvas 使用的是默认的 Screen Space Overlay,目标点标记用一个 Image 组件表示

WorldToScreenPoint(),顾名思义,输入值为 Vector3 世界坐标,输出为屏幕坐标。左下角为原点,1 单位代表 1 个像素,例如:1920*1080 分辨率下,屏幕中心点的坐标为[960, 540, z]。这里的 z 值是世界坐标到相机平面的距离。注意虽然返回值 1 单位对应 1 像素,但该值不一定是整数。

一行代码就能实现标记,但存在一个问题:背对目标点的时候,标记也会显示。如下图:

WorldToScreenPoint()的实现基本上可以概括为:将世界空间中的点先转换到相机空间,然后通过投射矩阵转换到模型空间——一个以原点为中心点的 2x2x2 的立方体。来自 unity 论坛的实现如下:

https://answers.unity.com/questions/1014337/calculation-behind-cameraworldtoscreenpoint.html

Vector3 manualWorldToScreenPoint(Vector3 wp) {
    // calculate view-projection matrix
    Matrix4x4 mat = cam.projectionMatrix * cam.worldToCameraMatrix;
 
    // multiply world point by VP matrix
    Vector4 temp = mat * new Vector4(wp.x, wp.y, wp.z, 1f);
 
    if (temp.w == 0f) {
        // point is exactly on camera focus point, screen point is undefined
        // unity handles this by returning 0,0,0
        return Vector3.zero;
    } else {
        // convert x and y from clip space to window coordinates
        temp.x = (temp.x/temp.w + 1f)*.5f * cam.pixelWidth;
        temp.y = (temp.y/temp.w + 1f)*.5f * cam.pixelHeight;
        return new Vector3(temp.x, temp.y, wp.z);
    }
}

由于WorldToScreenPoint()并不在乎物体是否在 view frustum 中,不在屏幕上的物体也会被映射。从 projection matrix 可以看出:

其中注意:

temp.x = (temp.x/temp.w + 1f)*.5f * cam.pixelWidth;
temp.y = (temp.y/temp.w + 1f)*.5f * cam.pixelHeight;

这两行将 projection matrix 转换后得到的 xy 值除以了 w 值(即-z)。这是为了让已经是齐次向量的 temp 落在前述模型空间内。由于除以 z 值将 xy 值的符号反转,上面的动图中可以看到以相机为中心点的中心对称效果。不想要标记在玩家背后出现的话,我们需要就 WorldToScreenPoint()z 值做一定处理。一种简单的做法是,在 z 值为负——也就是目标点在相机平面后方的时候,不进行位置更新:

if (newPos.z < 0) return;

这样,屏幕内标记就完成了。

屏幕外标记,方法 1

常见的屏幕外标记可以参考下图:

注意 UI 边缘的目标点提示。这些目标点均不在相机视野内。标记会根据目标点在视野外的方向调整其在屏幕边缘的位置。同时,标记会呈现在定义好的边界框中,不会覆盖其他 UI 元素。

有了上一节实现的屏幕内目标点,我们可以试试用Clamp()直接将标记坐标固定在边界框内。此处的 offset 均为像素值。

newPos.x = Mathf.Clamp(newPos.x, offsetLeft, Screen.width - offsetRight);
newPos.y = Mathf.Clamp(newPos.y, offsetDown, Screen.height - offsetUp);

看起来似乎工作良好,但在接近边缘时,标记出现了一些奇怪的行为:

注意边缘处标记的上下移动

当相机和目标不在同一 y 平面上时,标记似乎会在屏幕边缘先靠近一个角落,再从屏幕对角出现。这是为什么呢?我们可以让相机围绕 y 轴旋转,log 一下 newPosClamp 前的值:

可以看到,随着相机旋转以及相机平面靠近目标点,z 值越来越小,屏幕投影的值越来越大。上一节提到,转换过程中 xy 值需要除以 w(即-z),当目标点过于接近相机平面,整个向量就需要除以 0。实际上,如果目标点完美处于相机平面上,newPos 将会返回零向量;但实际游戏中,玩家几乎不可能通过操作实现这一情景,所以我们可以不对此进行边缘处理。标记在对角而不是邻角出现(如右下到左上,而不是右下到左下)则是因为 z 值正负的翻转在除以 -z 时转移到了 xy

xy 过大时,Clamp 就只能将其限制在屏幕的一角,这不是我们想要的。我们想要的效果是,标记在屏幕边缘的位置指向视角需要旋转的方向。注意截图中,即便在接近 90°的位置,xy 依然保持了一个比例。观察下图:

我们希望屏幕边缘的标记经过屏幕中心和目标点屏幕空间坐标的连线,这样它就能正确表示玩家需要移动准星的方向。单纯使用 Clamp 无法达成此效果,需要计算连线和屏幕边缘(限制区边缘)的交点。此处可以使用线段交点算法,但由于限制区的四边都平行于坐标轴,且直线过屏幕中心,用斜率表示法比较直观。

private Vector3 KClamp(Vector3 newPos)
{
    Vector2 center = new(Screen.width / 2, Screen.height / 2);
    float k = (newPos.y - center.y) / (newPos.x - center.x);

    if (newPos.y - center.y > 0)
    {
        newPos.y = Screen.height - offsetUp;
        newPos.x = center.x + (newPos.y - center.y) / k;
    }
    else
    {
        newPos.y = offsetDown;
        newPos.x = center.x + (newPos.y - center.y) / k;
    }

    if (newPos.x > Screen.width - offsetRight)
    {
        newPos.x = Screen.width - offsetRight;
        newPos.y = center.y + (newPos.x - center.x) * k;
    }
    else if (newPos.x < offsetLeft)
    {
        newPos.x = offsetLeft;
        newPos.y = center.y + (newPos.x - center.x) * k;
    }

    return newPos;
}

上述方法将任意点根据坐标与准星的相对方向限制在定义的边界框上。注意 offset 的值不要超过屏幕中心,因为该方法也会将边界框内部的点强行外推。此外,该方法仅在标记应该处在屏幕外的情况下使用。

现在来解决目标点在屏幕平面后方的情况。上文提到在 z=0 时,xy 值会翻转。一个绕过该问题的简单方法是,检测到目标在屏幕后时,将目标点投射到屏幕平面前方:

Vector3 delta = TargetTransform.position - camTransform.position;
float dot = Vector3.Dot(camTransform.forward, delta);

if (dot < 0)
{
    Vector3 projectedPos = camTransform.position + (delta - camTransform.forward * Vector3.Dot(camTransform.forward, delta) * 1.01f);
    newPos = Camera.main.WorldToScreenPoint(projectedPos);
}
else
{
    newPos = Camera.main.WorldToScreenPoint(TargetTransform.position);
}

检测目标点是否在屏幕后方也可以用屏幕空间坐标的 z 值或者相机空间坐标的 z 值,不过,因为投射本身会用到点积,这里复用了点积的值,减少一次 WorldToScreenPoint()调用。

最终效果见下图:

屏幕外标记,方法 2

方法 1 较为常见,采用此方法的游戏包括《战地 2042》《彩虹六号:围攻》《使命召唤:现代战争》《耻辱》等许多第一人称游戏。但这种一定程度上近似表示准星最短移动路径的标记,可能更适合 3 轴旋转的飞行模拟游戏,而不是 2 轴旋转、且仰角固定在-90°~90°之间的第一人称游戏。

例如,在完全背对目标点时,标记可能处于屏幕上边缘或下边缘,但第一人称角色抬头或低头受限,仍然需要水平旋转镜头才能看到后方,而飞行类游戏则可以通过不受限的俯仰直接瞄准目标点。

《守望先锋 2》的标记则为第一人称游戏进行了特化。背对目标点时,标记的 y 坐标也会忠实表示准星需要处于的仰角,而不会游离在屏幕上下边缘。

要实现这一行为,我们需要一种新的投射方式。既然处于同一以相机为起点的射线上的坐标,其透视变换后的屏幕坐标都相同,那么我们可以直接根据目标点与相机的角度,将目标点标准化为角度相同的单位向量,然后根据需求来限定角度的范围。

private void Method2()
{
    Transform camTransform = Camera.main.transform;

    var vFov = Camera.main.fieldOfView;
    var radHFov = 2 * Mathf.Atan(Mathf.Tan(vFov * Mathf.Deg2Rad / 2) * Camera.main.aspect);
    var hFov = Mathf.Rad2Deg * radHFov;

    Vector3 deltaUnitVec = (TargetTransform.position - camTransform.position).normalized;

    /* How the angles work:
     * vdegobj: objective vs xz plane (horizontal plane). Upright = -90, straight down = 90.
     * vdegcam: camera forward vs xz plane. same as above.
     * vdeg: obj -> cam. if obj is higher, value is negative.
     */

    float vdegobj = Vector3.Angle(Vector3.up, deltaUnitVec) - 90f;
    float vdegcam = Vector3.SignedAngle(Vector3.up, camTransform.forward, camTransform.right) - 90f;

    float vdeg = vdegobj - vdegcam;

    float hdeg = Vector3.SignedAngle(Vector3.ProjectOnPlane(camTransform.forward, Vector3.up), Vector3.ProjectOnPlane(deltaUnitVec, Vector3.up), Vector3.up);

    vdeg = Mathf.Clamp(vdeg, -89f, 89f);
    hdeg = Mathf.Clamp(hdeg, hFov * -0.5f, hFov * 0.5f);

    Vector3 projectedPos = Quaternion.AngleAxis(vdeg, camTransform.right) * Quaternion.AngleAxis(hdeg, camTransform.up) * camTransform.forward;
    Debug.DrawLine(camTransform.position, camTransform.position + projectedPos, Color.red);

    Vector3 newPos = Camera.main.WorldToScreenPoint(camTransform.position + projectedPos);

    if (newPos.x > Screen.width - offsetRight || newPos.x < offsetLeft || newPos.y > Screen.height - offsetUp || newPos.y < offsetDown)
        newPos = KClamp(newPos);

    img.transform.position = newPos;
}

此方法中,水平和垂直角度,分别是相机朝向与目标点朝向在世界 xz 平面和相机 yz 平面上的差值,依此投影出的目标点即可忠实地反映玩家准星需要进行的移动。效果如下图:

结语

以上就是本文介绍的两种屏幕外标记的实现方式。不同游戏在实现上会有些许不同,如《幽灵行动:断点》的标记限定范围使用的是椭圆而不是长方形(解出交点坐标即可实现),但基本原理几乎均为上文介绍的第一种方法。《守望先锋 2》属于少见的例外,不过由于其实现方式更符合(2 轴旋转)第一人称视角游戏的操作直觉,笔者认为有价值复现。

欢迎探讨!



封面:自制
*本文内容系作者独立观点,不代表 indienova 立场。未经授权允许,请勿转载。