第三人稱遊戲的相機控制

狐王驾虎發表於2024-06-10

第三人稱遊戲的相機控制

Unity已經有了Cinemachine這一強大的外掛來輔助開發者更容易地控制相機運動,但我覺得學習一下相機控制背後的原理還是挺有益的,沒準哪天就你像定製某種相機控制的功能,又覺得Cinemachine難調呢!

本文學習自 Jasper Flick 大神的 運動系列教程

相機的旋轉控制

  1. 相機會根據裝置輸入進行旋轉,給人一種扭頭的感覺。它可以是滑鼠的移動、某些按鍵組合等,總之就是一種二維向量資訊。再給定一個旋轉的速度來調節靈敏度,此外相機也不需要太敏感,可以忽視微小量的輸入

    //是否有操控相機旋轉
    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;
    }
    
  2. 通常我們還會限制其俯仰角 (避免底褲被看穿,在遊戲引擎中,就是限制相機繞x軸旋轉的角度;至於水平面的旋轉(偏航角),一般是不受限制,但為了配合其它相機運動的工作,會將這個值限定在 0 ~ 360 度。

    image image
    //約束角度
    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;
        }
    }
    
  3. 我們通常還需要將相機的朝向與角色的移動、轉向等相結合,最關鍵的一點就是提取相機朝向對於角色而言有用的分量:可以透過將相機的座標投影當前角色運動的平面(需要法線)來獲取。

image

相機聚焦

第三人稱視角的相機要「緊盯」目標,但不建議將相機作為觀測物件子物體的形式來實現這一目標。通常是讓相機與角色保持一定距離,控制相機旋轉時呈球面運動。在已知相機朝向的情況下,與觀測點逆向計算就可以得到位置:

image

為了不讓相機移動顯得太僵硬,和相機旋轉類似,我們對小範圍內的移動並不進行跟蹤。只有玩家超出那個範圍時相機才會跟蹤(為方便稱呼就叫它「死區半徑」)。

可以透過記錄上一次玩家超出死區半徑時的位置來做到:只有玩家當前位置與那個位置之間的距離再次超出死區半徑時,相機才進行跟蹤並更新那個位置的值,以便下次判斷。

//更新聚焦點
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之間的小數,這個值越小,向觀察物件處靠近就會越慢,反之越快。
為了讓兩種聚焦更好的融合,在超出死區半徑時,我們選用二者的最小值。下面是對比,左邊為取最小值;右邊是不取最小值,直接用原本的方案:

image image

注意右邊未採用最小值的情況下,在停止時會有明顯跟隨速度的變化,像是鏡頭被人往前推了一把。(因為gif幀率的原因,可能看不太出來)

相機碰撞

現在的相機只是個幽靈一樣的攝影師,我們希望它能更聰明點。比如,在相機與玩家之間隔了一堵牆時,我們希望它能越過那堵牆來拍攝角色,而不是嚴格保持著設定的距離、盯著牆壁或是卡在牆裡拍攝角色。

image image

這可以透過調整 相機的近裁剪面 做到,從觀測點向相機的近裁剪面處進行物理碰撞檢測,一旦發現碰撞點,就調整相機的位置,保證近裁剪面處於這個碰撞點的位置。

image

需要注意的就是,近裁剪面位置不等於相機位置,以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軸的角度:

image image

但要注意,用反三角函式求出來的這個角度要人為加以區分(例如透過其在x軸的分量正負號)。例如上圖的兩種情況,它們用反三角函式求出的角度是一樣的,不加以區分可能轉反。

private static float GetAngle(Vector2 direction)
{
    var angle = Mathf.Acos(direction.y) * Mathf.Rad2Deg;
    return direction.x < 0 ? 360f - angle : angle;
}

這樣一來就保證所有角度都是順時針而言的,所以上述第一種情況就是這樣的角度:

image

那就這樣把相機繞順時針旋過去,未免有點“捨近求遠”了吧?所以在實際旋轉之前,也要判斷一下怎麼旋角度變化比較小:

//是否需要自動對齊
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;
}

在原文中,作者還設計了一種特殊情況——在重力方向可變化的空間,這時相機該如何對齊?

image

很明顯,要在常規對齊的基礎上額外考慮重力作用下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;

相關文章