第三人稱遊戲的相機控制
Unity已經有了Cinemachine這一強大的外掛來輔助開發者更容易地控制相機運動,但我覺得學習一下相機控制背後的原理還是挺有益的,沒準哪天就你像定製某種相機控制的功能,又覺得Cinemachine難調呢!
本文學習自 Jasper Flick 大神的 運動系列教程
相機的旋轉控制
-
相機會根據裝置輸入進行旋轉,給人一種扭頭的感覺。它可以是滑鼠的移動、某些按鍵組合等,總之就是一種二維向量資訊。再給定一個旋轉的速度來調節靈敏度,此外相機也不需要太敏感,可以忽視微小量的輸入。
//是否有操控相機旋轉 private bool IsManualRotation(Vector2 cameraInput) { if(Mathf.Abs(cameraInput.x) > minInputValue || Mathf.Abs(cameraInput.y) > minInputValue) { OrbitAngles += rotationSpeed * Time.unscaledDeltaTime * cameraInput; return true; } return false; }
-
通常我們還會限制其俯仰角
(避免底褲被看穿,在遊戲引擎中,就是限制相機繞x軸旋轉的角度;至於水平面的旋轉(偏航角),一般是不受限制,但為了配合其它相機運動的工作,會將這個值限定在 0 ~ 360 度。//約束角度 private void ConstrainAngles () { //限制俯仰角 OrbitAngles.x = Mathf.Clamp(OrbitAngles.x, MinVerticalAngle, MaxVerticalAngle); //規範偏航角 if(OrbitAngles.y >= 360f) { OrbitAngles.y -= 360f; } else if(OrbitAngles.y < 0f) { OrbitAngles.y += 360f; } }
-
我們通常還需要將相機的朝向與角色的移動、轉向等相結合,最關鍵的一點就是提取相機朝向對於角色而言有用的分量:可以透過將相機的座標投影當前角色運動的平面(需要法線)來獲取。
相機聚焦
第三人稱視角的相機要「緊盯」目標,但不建議將相機作為觀測物件子物體的形式來實現這一目標。通常是讓相機與角色保持一定距離,控制相機旋轉時呈球面運動。在已知相機朝向的情況下,與觀測點逆向計算就可以得到位置:
為了不讓相機移動顯得太僵硬,和相機旋轉類似,我們對小範圍內的移動並不進行跟蹤。只有玩家超出那個範圍時相機才會跟蹤(為方便稱呼就叫它「死區半徑」)。
可以透過記錄上一次玩家超出死區半徑時的位置來做到:只有玩家當前位置與那個位置之間的距離再次超出死區半徑時,相機才進行跟蹤並更新那個位置的值,以便下次判斷。
//更新聚焦點
private void UpdateFocusPoint()
{
prevFocusPoint = focusPoint; //獲取上次的聚焦點
var curFocusPoint = Focus.position; //獲取觀察物件的位置
if(FocusRadius > 0) //如果有設定死區半徑
{
var curDis = Vector3.Distance(curFocusPoint, prevFocusPoint);
if(curDis > FocusRadius)
{
focusPoint = curFocusPoint;
}
}
else
{
focusPoint = curFocusPoint;
}
}
當然,這樣的處理會導致相機運動十分僵硬,畫面幾乎是抖動的。利用插值可以解決:
if(curDis > FocusRadius)
{
float lerpT = FocusRadius / curDis;
//選擇「當前」->「以前」插值,是因為 focusRaduis / curDis 是從1減少到0
focusPoint = Vector3.Lerp(curFocusPoint, prevFocusPoint, lerpT);
}
可這就不能保證相機與觀察物件的距離是期望值了,畢竟插值計算只發生在超出死區半徑的時候。所以我們在通常情況下,也讓相機緩緩向觀察物件處靠近,同樣是利用插值:
float lerpT = 1.0f;
if(curDis > 0.01f && FocusCentering > 0) //緩慢將聚焦點移到觀察物件位置處
{
lerpT = Mathf.Pow(FocusCentering, Time.unscaledDeltaTime);
}
if(curDis > FocusRadius) //超出死區半徑時
{
lerpT = Mathf.Min(lerpT, FocusRadius / curDis);
}
//選擇「當前」->「過去」插值,是因為 focusRaduis / curDis 是從1減少到0
focusPoint = Vector3.Lerp(curFocusPoint, prevFocusPoint, lerpT);
FocusCentering為值在0~1之間的小數,這個值越小,向觀察物件處靠近就會越慢,反之越快。
為了讓兩種聚焦更好的融合,在超出死區半徑時,我們選用二者的最小值。下面是對比,左邊為取最小值;右邊是不取最小值,直接用原本的方案:
注意右邊未採用最小值的情況下,在停止時會有明顯跟隨速度的變化,像是鏡頭被人往前推了一把。(因為gif幀率的原因,可能看不太出來)
相機碰撞
現在的相機只是個幽靈一樣的攝影師,我們希望它能更聰明點。比如,在相機與玩家之間隔了一堵牆時,我們希望它能越過那堵牆來拍攝角色,而不是嚴格保持著設定的距離、盯著牆壁或是卡在牆裡拍攝角色。
這可以透過調整 相機的近裁剪面 做到,從觀測點向相機的近裁剪面處進行物理碰撞檢測,一旦發現碰撞點,就調整相機的位置,保證近裁剪面處於這個碰撞點的位置。
需要注意的就是,近裁剪面位置不等於相機位置,以Unity為例,預設近裁剪面都會在相機前方0.3單位距離處,所以調整相機本體位置時,要考慮這部分的偏差。
//更新相機碰撞檢測
private void UpdateCameraCollision()
{
//nearClipPlane可以獲取近裁剪面與相機的距離
Vector3 rectOffset = lookDirection * camera.nearClipPlane; //近裁剪面與相機的偏差向量
Vector3 rectPosition = lookPosition + rectOffset; //相機近裁剪面位置
Vector3 castFrom = Focus.position; //因為是反向投射檢測,所以聚焦點是起始點
Vector3 castVector = rectPosition - castFrom; //起始點指向近裁剪面的向量
float castDistance = castVector.magnitude; //記錄該向量長度
//記錄該線段方向(已知長度可以直接除,等同於歸一化)
Vector3 castDirection = castVector / castDistance;
//利用上述資訊,進行盒狀投影檢測,判斷近裁剪面與觀察物件間有障礙
if(Physics.BoxCast(castFrom, CameraHalfExtends, castDirection, out RaycastHit hitInfo,
lookRotation, castDistance, ObstructionMask))
{
//移動到該碰撞點
rectPosition = castFrom + castDirection * hitInfo.distance;
//將該碰撞點位置減去近裁剪面,得到相機應該在的位置
lookPosition = rectPosition - rectOffset;
}
}
自動對齊
當相機在達到一定時間沒被操控時,相機會自動對齊玩家前進的方向,這也是第三人稱視角遊戲常有的功能。 (這似乎能提高遊戲體驗,但我沒想過這是為什麼
這個功能的重點是對齊的實現,首先,這裡的對齊是指在世界座標的XZ平面能與玩家運動保持一致,也就是說讓相機世界座標的y軸旋轉實現的。這樣才能保證相機的俯仰角不變。
我們可以記錄相機上一時刻聚焦的點,然後讓現在聚焦的點與之對比,便能求出運動向量,根據這個向量便能求出它對應的世界座標Z軸的角度:
但要注意,用反三角函式求出來的這個角度要人為加以區分(例如透過其在x軸的分量正負號)。例如上圖的兩種情況,它們用反三角函式求出的角度是一樣的,不加以區分可能轉反。
private static float GetAngle(Vector2 direction)
{
var angle = Mathf.Acos(direction.y) * Mathf.Rad2Deg;
return direction.x < 0 ? 360f - angle : angle;
}
這樣一來就保證所有角度都是順時針而言的,所以上述第一種情況就是這樣的角度:
那就這樣把相機繞順時針旋過去,未免有點“捨近求遠”了吧?所以在實際旋轉之前,也要判斷一下怎麼旋角度變化比較小:
//是否需要自動對齊
private bool IsAutoRotation()
{
if(Time.unscaledTime - lastManualRotateTime> attributes.AlignDelay)
{
//根據之前聚焦的位置和當前聚焦的位置,判斷觀察方向的變化
var alignDelta = focusPoint - prevFocusPoint;
var movement = new Vector2(alignDelta.x, alignDelta.z);
//不開根號是因為很多時候不用對齊,需要對齊時再開根號,省些計算量
var movementDeltaSqr = movement.sqrMagnitude;
if(movementDeltaSqr < 0.0001f) //角度變化很小就不用對齊了
{
return false;
}
//否則就算出該變化的角度
movement /= Mathf.Sqrt(movementDeltaSqr); //歸一化
var headingAngle = GetAngle(movement); //計算新朝向的角度
//得到從當前相機世界座標偏航角變化到上述角度的差值絕對值
var deltaAbs = Mathf.Abs(Mathf.DeltaAngle(OrbitAngles.y, headingAngle));
float rotationChange = RotationSpeed * Time.unscaledDeltaTime;
//以最小的旋轉角度旋轉過去,故順時針方向和逆時針方向都判斷一遍
if(deltaAbs < AlignSmoothRange)
{
rotationChange *= deltaAbs / AlignSmoothRange;
}
else if(180 - deltaAbs < AlignSmoothRange)
{
rotationChange *= (180 - deltaAbs) / AlignSmoothRange;
}
//插值變化角度,以求平滑過渡
OrbitAngles.y = Mathf.MoveTowardsAngle(OrbitAngles.y, headingAngle, rotationChange);
return true;
}
return false;
}
在原文中,作者還設計了一種特殊情況——在重力方向可變化的空間,這時相機該如何對齊?
很明顯,要在常規對齊的基礎上額外考慮重力作用下Up軸的變化。思路其實很相似,透過上一時刻Up軸與當前Up軸之間的角度,來插值變化:
//更新重力對齊
private void UpdateGravityAlignment()
{
//gravityAlignment為四元數,fromUp = 將up旋轉gravityAlignment之後的位置
//因為gravityAlignment記錄重力旋轉後的結果,故在未更新前,可認為是「上一幀的Up軸」
var fromUp = gravityAlignment * Vector3.up;
var toUp = CustomGravity.GetUpAxis(focusPoint);//當前重力下的up軸
var dot = Mathf.Clamp(Vector3.Dot(fromUp, toUp), -1, 1); //防止誤差而得到Nan結果
var angle = Mathf.Acos(dot) * Mathf.Rad2Deg;//獲取從fromUp與toUp間的夾角
var maxAngle = UpAlignmentSpeed * Time.deltaTime;
//新Up軸對齊四元數 = 新重力對齊旋轉 + 原本up軸
var newAlignment = Quaternion.FromToRotation(fromUp, toUp) * gravityAlignment;
if(angle <= maxAngle) //如果夾角在單幀變化的最大夾角限度內,直接應用變化
{
gravityAlignment = newAlignment;
}
else //否則插值變化
{
gravityAlignment = Quaternion.SlerpUnclamped(gravityAlignment, newAlignment, maxAngle / angle);
}
}
但這樣一來,在原本偏航角的對齊時,要排除掉重力翻轉的影響,不然會干擾對齊結果:
var alignDelta = Quaternion.Inverse(gravityAlignment) * (focusPoint - prevFocusPoint);
最後再一併算上:
orbitRotation = Quaternion.Euler(OrbitAngles);
//相機的旋轉由兩部分組成:重力軸對齊產生的旋轉和通常情況下對齊的旋轉
lookRotation = gravityAlignment * orbitRotation;