天天看點

Unity開發:兩種螢幕外目标點标記的實作方法

撰文:Quin7et

封面:自制

前言

近期在做個人項目的時候,需要實作一個提示目标點位置的标記 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);
}
}      

效果如圖:

Unity開發:兩種螢幕外目标點标記的實作方法

紅色球為目标點,綠色方塊為标記

這裡的 Canvas 使用的是預設的 Screen Space Overlay,目标點标記用一個 Image 元件表示

WorldToScreenPoint()

,顧名思義,輸入值為 Vector3 世界坐标,輸出為螢幕坐标。左下角為原點,1 機關代表 1 個像素,例如:1920*1080 分辨率下,螢幕中心點的坐标為[960, 540, z]。這裡的

z

值是世界坐标到相機平面的距離。注意雖然傳回值 1 機關對應 1 像素,但該值不一定是整數。

一行代碼就能實作标記,但存在一個問題:背對目标點的時候,标記也會顯示。如下圖:

Unity開發:兩種螢幕外目标點标記的實作方法

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 可以看出:

Unity開發:兩種螢幕外目标點标記的實作方法

其中注意:

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

這兩行将 projection matrix 轉換後得到的

x

y

值除以了

w

值(即

-z

)。這是為了讓已經是齊次向量的

temp

落在前述模型空間内。由于除以

z

值将

x

y

值的符号反轉,上面的動圖中可以看到以相機為中心點的中心對稱效果。不想要标記在玩家背後出現的話,我們需要就

WorldToScreenPoint()

z

值做一定處理。一種簡單的做法是,在

z

值為負——也就是目标點在相機平面後方的時候,不進行位置更新:

if (newPos.z < 0) return;      

這樣,螢幕内标記就完成了。

螢幕外标記,方法 1

常見的螢幕外标記可以參考下圖:

Unity開發:兩種螢幕外目标點标記的實作方法

注意 UI 邊緣的目标點提示。這些目标點均不在相機視野内。标記會根據目标點在視野外的方向調整其在螢幕邊緣的位置。同時,标記會呈現在定義好的邊界框中,不會覆寫其他 UI 元素。

有了上一節實作的螢幕内目标點,我們可以試試用

Clamp()

直接将标記坐标固定在邊界框内。此處的

offset

均為像素值。

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

看起來似乎工作良好,但在接近邊緣時,标記出現了一些奇怪的行為:

Unity開發:兩種螢幕外目标點标記的實作方法

注意邊緣處标記的上下移動

當相機和目标不在同一 y 平面上時,标記似乎會在螢幕邊緣先靠近一個角落,再從螢幕對角出現。這是為什麼呢?我們可以讓相機圍繞 y 軸旋轉,log 一下

newPos

Clamp

前的值:

Unity開發:兩種螢幕外目标點标記的實作方法
Unity開發:兩種螢幕外目标點标記的實作方法
Unity開發:兩種螢幕外目标點标記的實作方法
Unity開發:兩種螢幕外目标點标記的實作方法
Unity開發:兩種螢幕外目标點标記的實作方法
Unity開發:兩種螢幕外目标點标記的實作方法
Unity開發:兩種螢幕外目标點标記的實作方法

可以看到,随着相機旋轉以及相機平面靠近目标點,

z

值越來越小,螢幕投影的值越來越大。上一節提到,轉換過程中

x

y

值需要除以

w

(即

-z

),當目标點過于接近相機平面,整個向量就需要除以 0。實際上,如果目标點完美處于相機平面上,

newPos

将會傳回零向量;但實際遊戲中,玩家幾乎不可能通過操作實作這一情景,是以我們可以不對此進行邊緣處理。标記在對角而不是鄰角出現(如右下到左上,而不是右下到左下)則是因為

z

值正負的翻轉在除以

-z

時轉移到了

x

y

x

y

過大時,

Clamp

就隻能将其限制在螢幕的一角,這不是我們想要的。我們想要的效果是,标記在螢幕邊緣的位置指向視角需要旋轉的方向。注意截圖中,即便在接近 90°的位置,

x

y

依然保持了一個比例。觀察下圖:

Unity開發:兩種螢幕外目标點标記的實作方法

我們希望螢幕邊緣的标記經過螢幕中心和目标點螢幕空間坐标的連線,這樣它就能正确表示玩家需要移動準星的方向。單純使用

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

時,

x

y

值會翻轉。一個繞過該問題的簡單方法是,檢測到目标在螢幕後時,将目标點投射到螢幕平面前方:

Unity開發:兩種螢幕外目标點标記的實作方法
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()

調用。

最終效果見下圖:

Unity開發:兩種螢幕外目标點标記的實作方法

螢幕外标記,方法 2

方法 1 較為常見,采用此方法的遊戲包括《戰地 2042》《彩虹六号:圍攻》《使命召喚:現代戰争》《恥辱》等許多第一人稱遊戲。但這種一定程度上近似表示準星最短移動路徑的标記,可能更适合 3 軸旋轉的飛行模拟遊戲,而不是 2 軸旋轉、且仰角固定在-90°~90°之間的第一人稱遊戲。

例如,在完全背對目标點時,标記可能處于螢幕上邊緣或下邊緣,但第一人稱角色擡頭或低頭受限,仍然需要水準旋轉鏡頭才能看到後方,而飛行類遊戲則可以通過不受限的俯仰直接瞄準目标點。

《守望先鋒 2》的标記則為第一人稱遊戲進行了特化。背對目标點時,标記的 y 坐标也會忠實表示準星需要處于的仰角,而不會遊離在螢幕上下邊緣。

Unity開發:兩種螢幕外目标點标記的實作方法

要實作這一行為,我們需要一種新的投射方式。既然處于同一以相機為起點的射線上的坐标,其透視變換後的螢幕坐标都相同,那麼我們可以直接根據目标點與相機的角度,将目标點标準化為角度相同的機關向量,然後根據需求來限定角度的範圍。

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 平面上的內插補點,依此投影出的目标點即可忠實地反映玩家準星需要進行的移動。效果如下圖:

Unity開發:兩種螢幕外目标點标記的實作方法

結語

以上就是本文介紹的兩種螢幕外标記的實作方式。不同遊戲在實作上會有些許不同,如《幽靈行動:斷點》的标記限定範圍使用的是橢圓而不是長方形(解出交點坐标即可實作),但基本原理幾乎均為上文介紹的第一種方法。《守望先鋒 2》屬于少見的例外,不過由于其實作方式更符合(2 軸旋轉)第一人稱視角遊戲的操作直覺,筆者認為有價值複現。

歡迎探讨!

*本文内容系作者獨立觀點,不代表 indienova 立場。未經授權允許,請勿轉載。

Unity開發:兩種螢幕外目标點标記的實作方法

繼續閱讀