Unity 基於Cinemachine計算透視攝像機在地圖中的移動範圍
Unity中Cinemachine的基礎功能介紹可詳見之前寫的部落格:
https://www.cnblogs.com/koshio0219/p/11820654.html
本篇的重點是討論,在給定規則地圖的長寬和中心點座標的情況下,如何動態生成一個透視攝像機的碰撞盒子以限定攝像機的視野永遠不會超出地圖的邊界。
例如,下面這種規則地圖:(或者其他用程式生成的單位塊地圖)
在輸入一些引數後:
可以自動建立形如:
這樣的攝像機運動範圍,且輸出的範圍能夠適配到螢幕的解析度,考慮到相機繞某一軸向的旋轉等問題。
其實基本都是純粹的數學運算,開始之前,必須先弄清楚透視攝像機的一些基本原理,它的視窗大小和螢幕解析度之間到底是什麼關係:
1.FOV:這是透視攝像機區別於正交攝像機最重要的一個特性——視口大小,它表示的是當前攝像機視野範圍的開口角度,也因該角度大小的不同,使得透視攝像機的近裁剪平面和遠裁剪平面大小不一,從而產生三維空間中近大遠小的特點。
2.Aspect:當前攝像機的寬高比。為什麼要設定這樣一個東西呢?理由就是螢幕有不同的解析度,而相機映照出來的畫面最終是要在螢幕當中顯示的,當我們的螢幕解析度發生變化時,相機的視口面積也會對應的發生變化,這時,僅僅只有一個FOV沒辦法滿足不同型別的螢幕解析度,於是就需要額外設定相機的寬高比來對最終呈現的攝像機視口大小進行輔助調整。
在Unity中,是以視口的高為基準進行計算的,也就是說,Unity中的透視攝像機的Fov角度其實是按照螢幕分辯率的高度進行對應的,而寬度對應的Fov則隨著Aspect的變化而變化,不是面板設定的Fov大小。
試比較下面兩張圖,分別是攝像機的寬和高的Fov:
設定的Fov為40度,當前的螢幕解析度為2960*1440:
很顯然,只有高度對應的Fov為面板中顯示的值,而寬度對應的Fov明顯大於40度。實際寬的的Fov應該是82度左右(40*2960/1440)。
知道了上面這些後我們才能更愉快的進行接下來的計算,不然只會計算出許多錯誤也搞不清是什麼原因。
在Cinemachine中,一般會設定一個跟隨目標,且跟蹤該目標的距離是一個常量,可以從面板中取得:
我們先分析攝像機的左右運動範圍是如何計算的:(本例中的攝像機只在X軸向上存在旋轉值,一般斜向的攝像機也只需要旋轉一個軸即可,左右看上去一般追求對稱性)
觀察上圖,假設現在攝像機位於空中的P點,已知AB為地圖的邊緣圍牆高度,BC為角色的高度,CP為跟蹤的攝像機到角色的距離,現在我們需要求出攝像機所在的X軸向的座標,關鍵就是要求出AD的距離。
我們還知道一個數據就是攝像機的Fov,但是由於該Fov並非高度對應的值,所以我們先要進行一次轉換,以得到攝像機寬度視口的Fov角度。以下均為弧度計算:
1 //計算的角度均為弧度值,傳入縱向的(高)Fov的一半得到橫向的(寬)Fov的一半 2 public float GetHorizontalFovHalf(float vhfov, float aspect) 3 { 4 return Mathf.Atan(Mathf.Tan(vhfov) * aspect); 5 }
上面已經講過原理了這裡就不在進行過多敘述了,簡單來說就是利用攝像機的深度值進行了一次轉換,因為無論是縱向還是橫向的Fov,它們的深度值都是相同的,讀者可以自行畫圖或腦補一下。
通過上面的方法我們就可以求得∠DPA的大小了,它正好就是橫向Fov的一半,那個∠α的大小就可以輕易求出,現在問題的關鍵就是要求出邊AP的長度,AP的長度得出的話,就可以利用∠α餘弦求得AD,DP等。
利用正弦定理可以非常快速的解決上面的問題,當然你也可以設未知數利用勾股定律解一元二次方程,但當你寫程式的時候你可能會有想吐的衝動:
1 //計算軸向偏移值 2 private float GetSizeOffse(float fbangel, float distance, float wh, float followy) 3 { 4 //直角弧度值 5 var rightangel = 90 * Mathf.Deg2Rad; 6 //∠PAC 7 var disangel = fbangel + rightangel; 8 //求出正弦定理的比值 9 var sin = distance / Mathf.Sin(disangel); 10 //求∠APC的正弦值 11 var angelo = (wh - followy) / sin; 12 //三角形內角和求∠ACP 13 var angel = rightangel * 2 - Mathf.Asin(angelo) - disangel; 14 //計算AP利用α餘弦返回AD 15 return sin * angel * Mathf.Cos(fbangel); 16 }
fbangel即為上圖中的∠α,distance即為上圖中的CP,wh即為上圖中的AB,followy即為上圖中的CB。
X軸向的偏移計算完畢後,Z軸的偏移也是類似的,只不過需要考慮旋轉值而已,接下來就是攝像機的高度(注意攝像機的高度是一個變數),這個很容易計算。下面給出生成攝像機運動區域的參考:
1 //計算並生成透視攝像機的運動區域 2 public void GenZone() 3 { 4 Camera = Camera.main; 5 6 //計算從地圖中心到邊緣的向量 7 var toedge = WidthHeight * UnitLength * .5f; 8 //左後 9 var lb = CenterPoint - toedge; 10 //右前 11 var rf = CenterPoint + toedge; 12 //牆高 13 var wh = WallHeight; 14 15 zone = new GameObject("CameraZone"); 16 17 var box = zone.AddComponent<BoxCollider>(); 18 var cvc = GetComponent<CinemachineVirtualCamera>(); 19 var cft = cvc.GetCinemachineComponent<CinemachineFramingTransposer>(); 20 21 var cvcs = cvc.m_Lens; 22 //攝像機跟蹤目標的高度 23 var followy = cvc.m_Follow.position.y; 24 //跟蹤距離 25 var distance = cft.m_CameraDistance; 26 //螢幕高對應的Fov(真實Fov) 27 var hfov = cvcs.FieldOfView * .5f * Mathf.Deg2Rad; 28 //攝像機視口寬高比 29 var aspect = Camera.aspect; 30 //攝像機軸向旋轉值 31 var rotation = Camera.transform.eulerAngles.x * Mathf.Deg2Rad; 32 var rightangel = 90 * Mathf.Deg2Rad; 33 //螢幕寬對應的Fov(轉化後的Fov) 34 var whfov = GetHorizontalFovHalf(hfov, aspect); 35 36 //攝像機當前高度 37 var height = Mathf.Sin(rotation) * distance + followy; 38 39 //計算左右偏移(對稱) 40 var lrangel = rightangel - whfov; 41 var widthh = GetSizeOffse(lrangel, distance, wh, followy); 42 var left = lb.x + widthh; 43 var right = rf.x - widthh; 44 var sizex = Mathf.Abs(left - right); 45 46 //計算前後偏移(帶旋轉值,非對稱) 47 var fangel = rotation - hfov; 48 var front = rf.y - GetSizeOffse(fangel, distance, wh, followy); 49 50 var bangel = rotation + hfov; 51 var back = lb.y - GetSizeOffse(bangel, distance, wh, followy); 52 53 var sizez = Mathf.Abs(front - back); 54 55 //設定攝像機運動範圍的大小,因為在XZ平面上,盒子的高度可以為一個常量 56 box.size = new Vector3(sizex, 5, sizez); 57 zone.transform.position = new Vector3((left + right) * .5f, height, (front + back) * .5f); 58 59 CC.m_BoundingVolume = zone.GetComponent<BoxCollider>(); 60 }
生成該盒子後,只需要將它賦值給CinemachineConfiner的BoundingVolume屬性即可:
為了更方便的進行測試和除錯,可以寫一個Editor指令碼在編輯器模式下生成:
1 using UnityEditor; 2 using UnityEngine; 3 4 [CustomEditor(typeof(CameraZoneCtrl))] 5 public class CameraZoneEditor : Editor 6 { 7 public override void OnInspectorGUI() 8 { 9 DrawDefaultInspector(); 10 CameraZoneCtrl ctrl = (CameraZoneCtrl)target; 11 if (GUILayout.Button("建立攝像機範圍")) 12 { 13 ctrl.GenZone(); 14 } 15 } 16 }