Unity中實現人形角色的攀爬

狐王驾虎發表於2024-09-08

在Unity實現角色攀爬

前言

開放世界型別的遊戲近年也熱門起來了,自由攀爬也成了這一類遊戲的一大特色。攀爬給了玩家更多探索路徑的選擇,也讓地圖設計有了更多思路。這次,我們就來嘗試在Unity中製作一個人形角色的攀爬。

image

注:攀爬是一個角色完整動作系統的一部分,本文暫且拋開其它動作,也不涉及動畫,僅針對攀爬邏輯的實現這一點。

主要實現

首先,我們要意識到,遊戲中的攀爬行為已經與物理系統沒有太大關係了。在攀爬時,角色實際上進入了一種“懸浮”狀態,然後貼著牆面運動。攀爬系統要做好,就在於如何能讓角色貼著牆面運動。

或許說到這,你腦海裡已經想到了很多千奇百怪的攀爬面,但其實,任何攀爬面只要能抓住其法線,一切都好解決很多了:

  1. 先寫一個輔助函式,將向量投影在某一法線所屬平面(得到的就是那條深藍色向量):

    image
    /// <summary>
    /// 獲取向量在某一平面的投影
    /// </summary>
    /// <param name="vector">要投影的向量</param>
    /// <param name="planeNormal">平面的法線(需歸一化)</param>
    /// <returns>在平面的歸一化投影向量</returns>
    public static Vector3 GetProjectOnPlane(Vector3 vector, Vector3 planeNormal)
    {
        return (vector - planeNormal * Vector3.Dot(vector, planeNormal)).normalized;
    }
    
  2. 如果能獲取攀爬面的法線,我們就可以將角色的運動方向轉化為在攀爬面上的運動方向:

    var newXAxis = GetProjectOnPlane(xAxis, contactNormal);
    var newZAxis = GetProjectOnPlane(zAxis, contactNormal);
    

那現在的問題就在於如何準確獲取攀爬面的法線?或許你會想到用射線檢測,但遇到內角與外角的情況又該如何檢測呢:

image image

難不成要換成其它形狀的碰撞檢測?其實沒必要這麼麻煩,我們完全可以利用角色自身的碰撞體接觸來判斷。大多數情況下,人形角色都是使用Capsule Collider(膠囊體碰撞盒),能較“均勻”地觸碰接觸面,比如在接觸到內直角的情況下,將所有接觸點的法線累加再求平均,得到的平均法線向量是接近45度,這是膠囊體曲面性質導致的。

private void OnCollisionEnter (Collision collision) 
{
    EvaluateCollision(collision);
}
private void OnCollisionStay(Collision collision)
{
    EvaluateCollision(collision);
}

/// <summary>
/// 在OnCollisionEnter和OnCollisionStay中呼叫,用於獲取接觸到的碰撞有效資訊
/// </summary>
/// <param name="other">接觸的碰撞體</param>
public void EvaluateCollision(Collision collision)
{
    int layer = collision.gameObject.layer;
    for(int i = 0; i < collision.contactCount; ++i) //檢查接觸點型別並記錄對應型別的法線
    {
        Vector3 normal = collision.GetContact(i).normal;
        float upDot = Vector3.Dot(upAxis, normal);
        //如果當前可以攀爬、攀爬面層級為可攀爬層級、攀爬面的傾斜角度未超過最大攀爬角度
        if(isAllowedClimb && ((1<<layer) & climbMask) != 0 && upDot >= minClimbDot)
        {
            ++climbContactCnt; //統計接觸點數量,便於後續求平均
            climbNormal += normal; //累加攀爬法線,便於後續求平均
            lastClimbNormal = normal;
        }
    }
}

/// <summary>
/// 檢測攀爬並更新、歸一化攀爬法線,攀爬時呼叫
/// </summary>
/// <returns>true為可攀爬,false為不可攀爬</returns>
public bool CheckClimb()
{
    if(IsClimbing)
    {
        if(climbContactCnt > 1)
        {
            climbNormal.Normalize();
            //如果處於裂縫中(四周攀爬面法線和為地面),就取檢測到的最後一個面為攀爬面
            var upDot = Vector3.Dot(upAxis, climbNormal);
            if(upDot >= minGroundDot)
            {
                climbNormal = lastClimbNormal;
            }
        }
        contactNormal = climbNormal;
        return true;
    }
    return false;
}

這裡提兩點:

  1. 為什麼upDot >= minClimbDot可以用來判斷是否更傾斜?
    在往期文章中,我有提到過:假設法線的長度都為1,可以發現當地面越來越陡峭時,法線在豎直方向上的投影,也就是它的cos值會越來越小,直到地面完全垂直(變成牆壁)時,這個值會變成0。所以,只要事先將「可攀爬的最大角度」的cos值算出,我們就能將角度的比較轉為數值的比較。

    [SerializeField, Range(90, 180), Tooltip("最大攀爬角度")]
    private float maxClimbAngle = 140f;
    
    minClimbDot = Mathf.Cos(maxClimbAngle * Mathf.Deg2Rad);
    
    image
  2. 為什麼要記錄lastClimbNormal
    這是為了防止類似下圖這種情況,角色接觸面法線的平均為Vector3.zero,此時角色將爬不動,故將「最後接觸到的那條法線」作為攀爬面法線。

    image

上述這些就已經能接近內角的問題了,但外角還需要一點額外處理——擠壓,攀爬時向角色持續施加一個沿著法線向牆面的力:

float maxClimbAcceleration = 40f;//攀爬時的加速度
//用於攀爬外牆角時貼緊牆面
Velocity -= contactNormal * (0.9f * maxClimbAcceleration * Time.fixedDeltaTime);

取90%的攀爬運動的加速度作為這個力的大小,可以保證擠壓力不讓角色動彈不得。現在,內外角的攀爬就沒有太大問題了(紅色的是接觸面法線):

image

額外調整

然而,事情並沒有結束。攀爬時我們通常還會讓角色始終面向攀爬面,這就需要我們在攀爬時適時旋轉角色,這也不困難:

public void ClimbForward() //旋轉以面向攀爬牆面
{
    if(sensor.climbNormal != Vector3.zero)
    {
        var forwardQ = Quaternion.LookRotation(-climbNormal, upAxis);
        transform.rotation = forwardQ;
    }
}

而一旦這麼做了,那麼當你嘗試攀爬以下形狀的面,會發現角色頻頻抽搐、退出攀爬狀態:

image

為什麼會這樣?我們來分析下這個過程:

image
  1. 角色沿著牆面向上爬,一切正常
  2. 當角色頂部接觸到上斜面時,計算出的法線發生了改變,角色也要進行旋轉以面向新法線
  3. 問題就發生在這裡,角色是膠囊體,在旋轉後可能就與牆面衝突了。而且旋轉後接觸牆面的區域也變了,法線又發生了變化,而法線一旦變化,角色又得旋轉,而一旦旋轉後……

不難看出,罪魁禍首其實是膠囊體(攀爬的實現邏輯不改變的話(。・ω・。))!膠囊體橫向旋轉時勢必會影響接觸區域,導致計算出的法線變化,從而帶來一系列問題。除非你的角色是個球形的,這樣,隨便旋轉都不會影響接觸區域了……

image

是啊!不妨僅在攀爬時將角色的碰撞體進行「變形」,從膠囊體變為球體:

private void SetClimbCollider(bool isClimbing)
{
    if(isClimbing)//正在攀爬時,將膠囊體的高度設為0,就變球體了
    {
        playerCollider.height = 0;
        playerCollider.radius = climbColliderRadius;
        playerCollider.center = climbColliderCenter;
    }
    else //退出攀爬時,將引數還原以變回原本的膠囊體
    {
        playerCollider.height = colliderHeight;
        playerCollider.radius = colliderRadius;
        playerCollider.center = colliderCenter;
    }
}

除了將膠囊體高度設為0,我們還適度增加了膠囊體半徑,以及將中心偏移,通常是改成能覆蓋角色上半身的情況 攀爬時,腿真的不重要了

image

萬事大吉了嗎?還差一步,仍是旋轉的問題。

image

一般的人形角色的根物體位置只是角色底部中心處,平時涉及的運動也是圍繞這個點進行的,但如今我們想要角色能繞調整後的球形碰撞體的球心旋轉,因為只是繞根位置旋轉,很可能讓角色失去碰撞接觸(示意圖中,將角色模型簡化成了棒棒糖形狀):

image

這就相當於將根物體位置改為球心了呀,這還有點麻煩,畢竟會干擾原本的運動邏輯。除非有什麼巧妙的旋轉策略,鄙人倒是有個想法,定然不是最好的,大家如果自己有思路,也可以跳過這段,程式碼如下:

image
//繞攀爬時的球心(攀爬時膠囊體會變成球體)旋轉,以面向攀爬牆面
public void ClimbForward() 
{
    /*總體思路: 先繞根位置旋轉以調整面朝的方向,但旋轉點並不是球心
    只是這樣旋轉的話,必定會讓球心偏離原來位置,
    所以要讓playerTransform補回那段距離,以讓球心回到旋轉前的位置
    這樣就實現了:既朝向了攀爬法線,球心位置又不改變 = 繞球心旋轉*/
    if(sensor.climbNormal != Vector3.zero)
    {
        var originCenter = playerTransform.position + playerTransform.up * playerCollider.center.y;
        var forwardQ = Quaternion.LookRotation(-sensor.climbNormal, sensor.upAxis);
        playerTransform.rotation = forwardQ;
        var newCenter = playerTransform.position + playerTransform.up * playerCollider.center.y;
        playerTransform.position += originCenter - newCenter;
    }
}

尾聲

有關攀爬的核心實現大概就這些了,再次強調一遍,這是完整動作系統的一部分,我從自己實現的一個專案中剝離出來的,顯得不太完整(因為完整的會涉及很多為了配合其它動作而設的一些變數,有點喧賓奪主了),本身只是一個思路分享,大夥有更多想法也可以分享出來呀~當然,有不滿之處也可指出(。・・)ノ

相關文章