人形動畫常見IK的處理

狐王驾虎發表於2024-07-20

Unity中常見人形動畫IK的處理方式

本文將嘗試僅使用Untiy內建的Animator來解決常見的幾種運動所需的IK。也會給出核心功能的程式碼實現。

效果一覽:b站影片

Unity中人形角色的IK

IK(inverse kinematics)也就是逆運動學,在工業機器人領域,人們關注的逆運動學問題就是透過末端執行器的位姿來求解對應的關節變數。而在遊戲中也類似,我們關注的就是根據末端肢體的位姿來調整身體其它部分的位置,好在Unity已經幫我們解決了這個複雜的求解過程。對於使用Avatar的人形動畫,Unity內建的Animator允許我們調整5個部位的IK:頭、左手、右手、左腳、右腳 (被封印的艾克佐迪亞。總的來說,我們只要設定好這些IK的位置和旋轉就可以了,Unity會自動調整角色的骨骼。

image

PS:左腳、右腳、左手、右手的IK設定可以透過Animator.SetIKPosition等系列函式,透過AvatarIKGoal的列舉來選擇部位;而頭部則透過Animator.SetIKPosition等系列函式來控制。

這些函式要在OnAnimatorIK生命週期函式中呼叫才奏效

image
image

站立、奔跑IK

這應該是人形角色最常規的IK了,通常的站立、奔跑、行走等動畫都預設是在水平地面上的。但實際遊戲地形會複雜很多,我們就需要調節足部的IK來貼合不同的地面。

image

1. 接觸面法線

首先要做的就是透過物理檢測找到「落腳點」,簡單的射線檢測就可以做到,射線檢測返回的RaycastHit引數會告訴我們接觸點和接觸點的法線,以此就可以來調整腳的位置與姿態。

image
  1. 透過animator.GetBoneTransform得到腳部骨骼的Transform,進而得到腳部骨骼position。從該位置上方一段距離開始,向下檢測接觸面。人形角色通常是膠囊體,所以邁步時,腳很有可能就超出了膠囊體範圍,而指令碼身又沒有碰撞體,就容易進入碰撞體內部,這時如果只是從指令碼身開始檢測就會檢測失敗,所以從上方開始檢測。
image image
/// <summary>
/// 實現類似 pointA.axis = pointB.axis + offset 指定軸向的變化
/// </summary>
private void FoottCheck(HumanBodyBones footBone, int iKGoal_Int, Vector3 upAxis)
{
    var footPos = animator.GetBoneTransform(footBone).position;
    //足部上移一段距離後的位置作為射線起點
    var originPos = footPos + upAxis * upOffset;
    //檢測時指定地面層級遮罩,一般可以忽視觸發器
    if(Physics.Raycast(originPos, -upAxis, out hitInfo, checkRayLength, 
        checkMask, QueryTriggerInteraction.Ignore))
    {
        //不直接將足部位置設定為檢測到的hit.point
        //而是將hit.point在upAxis上的分量賦值給足部
        //相當於把足部沿upAxis方向移到hit.point等高度
        CalculateAxisValue(ref footPos, hitInfo.point, upAxis);
        //記錄下調整後的足部位置
        iKGoalPositions[iKGoal_Int] = footPos;
        //記錄下從upAxis到接觸面法線所需的旋轉
        iKGoalRotations[iKGoal_Int] = Quaternion.FromToRotation(upAxis, hitInfo.normal);
    }
}

/// <summary>
/// 輔助函式,功能: pointA.axis = pointB.axis + offset
/// </summary>
private void CalculateAxisValue(ref Vector3 pointA, Vector3 pointB, Vector3 axis, float offset = 0)
{
    pointA += axis * (Vector3.Dot(pointB - pointA, axis) + offset);
}

2. 調整質心位置

光調整腳的位置是不夠的,因為這樣容易出現一隻腳夠得著平面,但另一隻則 「虛空接觸」 的情況(左側就是沒調整的,右側就是調整後的):

image image

這也是上一步中,要用彆扭的方法移動腳部的原因。這樣我們就能算得哪隻腳觸碰接觸面所需要移動的距離較大了,我們就將較大的這個偏移量同步應用到animator.bodyPositon就可以了!

/// <summary>
/// 根據足部在當前up軸的偏差來調整質心位置(身體升降)
/// 為了讓奔跑連貫,奔跑時不建議開啟,僅靜止時開啟
/// </summary>
/// <param name="isIdle">是否是閒置狀態</param>
private void MoveCentroidPosition(bool isIdle)
{
    if (isIdle && iKGoalPositions[leftFoot_Idx] != Vector3.zero && iKGoalPositions[rightFoot_Idx] != Vector3.zero 
        && lastCentriodPosInUpAxis != 0) //非閒置時、未獲取正確資訊時不做調整
    {
        var animTransform = animator.transform;
        //取離軀體最遠的腳(更需要貼近地面的腳)與身體的差距作為偏移值
        var leftOffset = Vector3.Dot(animTransform.up, iKGoalPositions[0] - animTransform.position);
        var rightOffset = Vector3.Dot(animTransform.up, iKGoalPositions[1] - animTransform.position);
        finalCentroidOffset = leftOffset < rightOffset ? leftOffset : rightOffset;
        //在指定方向上線性逼近
        Vector3 newCentroidPos = animator.bodyPosition + animTransform.up * finalCentroidOffset;
        float newCentroidPosInUpAxis = Vector3.Dot(animTransform.up, newCentroidPos);
        //用插值的方式改變質心位置,更自然
        newCentroidPosInUpAxis = Mathf.Lerp(lastCentriodPosInUpAxis, newCentroidPosInUpAxis, centroidMoveSpeed);
        CalculateAxisValue(ref newCentroidPos, Vector3.zero, animTransform.up, newCentroidPosInUpAxis);
        //應用調整後的位置
        animator.bodyPosition = newCentroidPos; 
    }
    //將當前質心位置記錄為「上次質心在upAxis上的位置」,方便下一幀判斷
    lastCentriodPosInUpAxis = Vector3.Dot(animTransform.up, animator.bodyPosition);
}

你可能注意到了,質心調整並不一定要時時開啟,否則像快速上樓梯等斜面變化頻繁的情況,可能會劇烈抖動

image

3. 保持原本朝向

我們希望足部在調整後仍能保持動畫原本的偏航角,(也就是說該外八的還是外八,內八的還是內八;而如果像這麼做的話,就會導致腳筆直朝向玩家前方:

iKGoalRot = iKGoalRotations[leftFoot_Int] * animator.transform.rotation;
animator.SetIKRotation(AvatarIKGoal.LeftFoot, iKGoalRot);

顯然,問題就出在我們是基於animator.transform.rotation來調整的。所以我們應該在真正調整朝向前,先記錄腳部IK原本的朝向,再在記錄下的這個朝向上應用步驟1中得到的「貼合地面的旋轉」。

private void MoveFeetToIKPos(AvatarIKGoal iKGoal, int iKGoal_Int)
{
    //真正調整前,先記錄原本IK的位置和朝向
    var animTransform = animator.transform;
    var iKGoalPos = animator.GetIKPosition(iKGoal);
    var iKGoalRot = animator.GetIKRotation(iKGoal);
    //如果FixedUpdate中沒有檢測到資訊就不更新IK
    if(iKGoalPositions[iKGoal_Int] != Vector3.zero) 
    {
        //將當前IKGoal位置和目標IKGoal位置都轉到當前座標系下
        iKGoalPos = animTransform.InverseTransformPoint(iKGoalPos);
        iKGoalPositions[iKGoal_Int] = animTransform.InverseTransformPoint(iKGoalPositions[iKGoal_Int]);
        //從當前座標的y方向線性逼近目標IKGoal,同樣插值逼近顯得自然
        var upVar = Mathf.Lerp(lastPosInUpAxis[iKGoal_Int], iKGoalPositions[iKGoal_Int].y, footIKMoveSpeed);
        iKGoalPos.y += upVar;
        lastPosInUpAxis[iKGoal_Int] = upVar;
        //將調整後的位置轉回世界座標空間(因為SetIKPosition是根據世界座標的)
        iKGoalPos = animTransform.TransformPoint(iKGoalPos);
        //四元數旋轉:原本足部旋轉的基礎上 + 地面貼合旋轉
        iKGoalRot = iKGoalRotations[iKGoal_Int] * iKGoalRot;
        animator.SetIKRotation(iKGoal, iKGoalRot);
    }
    animator.SetIKPosition(iKGoal, iKGoalPos);
    //清空資訊,以待下次FixedUpdate提供資訊
    iKGoalPositions[iKGoal_Int] = Vector3.zero;
}

攀爬IK

通常人形動畫的攀爬要調整的是四肢的位置,使其貼合牆面。

攀爬IK的設定方式其實和你所實現攀爬系統的邏輯密切相關,我就暫定現在我們已經實現好了一個攀爬系統,它能時時獲取攀爬法線

1. 四肢貼合

最簡單的環境,實現思路與足部貼合地面類似,獲取四肢IKGaol的位置,然後沿角色後方遠離一段距離作為射線檢測的起點,往角色的前方進行檢測。如下圖所示(紅色端為射線起點)

image
/// <summary>
/// 透過射線檢測調整攀爬時四肢IK位置、旋轉,並將結果儲存在陣列中
/// </summary>
private void LimbsClimb_Solver(int iKGoal_Int, LayerMask climbMask)
{
    var animTransform = animator.transform;
    //這裡假設在攀爬系統的作用下,角色總能面朝攀爬面,故用forward
    var origin = limbsPositions[iKGoal_Int] - animTransform.forward * limbOffset;
    if(Physics.Raycast(origin, animTransform.forward, out hitInfo, 
        climbRayLength, climbMask, QueryTriggerInteraction.Ignore))
    {
        iKGoalPositions[iKGoal_Int] = hitInfo.point;
        iKGoalRotations[iKGoal_Int] = Quaternion.FromToRotation(animTransform.forward, -hitInfo.normal);
        return;
    }
}

「遠離一段距離」還有其它好處,比如貼合這種上沿或者內拐角

image image

2. 保持身體與攀爬面的距離

讓角色的身體與牆面保持一定距離,可以讓動畫看起來更順眼。因為這位置只和牆面有關,所以調整起來也很簡單(需要用到攀爬法線climbNormal):

/// <summary>
/// 調整身體離牆的距離
/// </summary>
private void AdjustBodyPos(Vector3 climbNormal, LayerMask climbMask)
{
    if(Physics.Raycast(animator.bodyPosition, -climbNormal, out hitInfo, 
        climbCornerRayLength, climbMask, QueryTriggerInteraction.Ignore))
    {
        animator.bodyPosition = hitInfo.point + climbNormal * climbDisWithWall;
    }
}

3. 適應外拐角

有一種比較麻煩的地方是「外拐角」,步驟1中的前向射線檢測會撲空。我們需要從兩側向中間檢測

image image

具體思路就是四肢向內側方向進行檢測。而且要多段檢測,也就是將射線起點向前移動幾次,能更好貼合V形角(就算沒有刻意的V形牆面,當角色爬過外牆角時也會變成面向V形角的情況)

image

我們對步驟1中的函式進行補充:

/// <summary>
/// 透過射線檢測調整攀爬時四肢IK位置、旋轉,並將結果儲存在陣列中
/// </summary>
private void LimbsClimb_Solver(int iKGoal_Int, LayerMask climbMask)
{
    var animTransform = animator.transform;
    //這裡假設在攀爬系統的作用下,角色總能面朝攀爬面,故用forward
    var origin = limbsPositions[iKGoal_Int] - animTransform.forward * limbOffset;
    if(Physics.Raycast(origin, animTransform.forward, out hitInfo, 
        climbRayLength, climbMask, QueryTriggerInteraction.Ignore))
    {
        iKGoalPositions[iKGoal_Int] = hitInfo.point;
        iKGoalRotations[iKGoal_Int] = Quaternion.FromToRotation(animTransform.forward, -hitInfo.normal);
        return;
    }
    //——————————————————新增部分————————————————
    else //當前向射線檢測不到時,大機率進入了外拐角
    {
        //射線起點回到原本位置
        origin += animTransform.forward * limbOffset;
        //根據肢體所屬左右來設定檢測方向
        var dir = (iKGoal_Int & 1) == 0 ? animTransform.right : -animTransform.right;
        //向中間進行多次射線檢測
        for(int i = 0; i < cornerRayCount; ++i)
        {
            if(Physics.Raycast(origin, dir, out hitInfo, 
                climbCornerRayLength, climbMask, QueryTriggerInteraction.Ignore))
            {
                iKGoalPositions[iKGoal_Int] = hitInfo.point;
                iKGoalRotations[iKGoal_Int] = Quaternion.FromToRotation(animTransform.forward, -hitInfo.normal);
                return;
            }
            //如果這次沒檢測到,就將起點前移
            origin += cornerRayGap * animTransform.forward;
        }
    }
}

瞄準IK

第三人稱射擊遊戲的瞄準,需要讓玩家的頭能朝向瞄準的地方,玩家拿槍的手也指向瞄準的地方。

image image

1. 頭部朝向

頭部的處理,我倒是比較簡單。因為我的角色會轉身,所以頭部只需要調整俯仰角就可以了。而頭部朝向不一定要百分百朝著瞄準點,看著像個樣子就差不多,所以我的選擇是——看向手裡武器

public void HeadLookAt(Vector3 weaponPos, float weight)
{
    animator.SetLookAtPosition(weaponPos);
    animator.SetLookAtWeight(weight);
}

2. 手臂朝向

調整手臂朝向的一大難點是保持手部姿勢,直接設定朝向容易破壞持械姿勢。

image

我的想法是:讓雙手IK的上下活動限制在一個球面上,這樣一來,無論雙臂朝向何方手臂伸展的距離都不會變化,這樣就能保證動畫的姿勢維持。

image
image

至於這個球心位置,我是簡單地選擇角色胸骨骼位置,效果還行,動作變形程度不會很大(也可能是因為角色拿著手槍的原因)

public void BodyLookAt(Vector3 pos)
{
    //奔跑時胸骨骼會上下移動,瞄準方向會劇烈變化,選bodyPosition來算方向更穩定
    Vector3 handIKPos, dir = (pos - animator.bodyPosition).normalized;
    Vector3 chestPos = animator.GetBoneTransform(HumanBodyBones.Chest).position;
    
    //雙手IK位置調整
    var handIKGoal = AvatarIKGoal.LeftHand;
    handIKPos = animator.GetIKPosition(handIKGoal);
    var originDis = (chestPos - handIKPos).magnitude; //保持半徑距離,圓形擺動
    handIKPos = chestPos + dir * originDis;
    //奔跑時胸骨骼可能會小幅度上下移動,讓手部IK位置也做同樣移動
    animator.SetIKPosition(handIKGoal, handIKPos + animTransform.up * animator.deltaPosition.y);
    animator.SetIKPositionWeight(handIKGoal, 1);

    var handIKGoal = AvatarIKGoal.RightHand;
    handIKPos = animator.GetIKPosition(handIKGoal);
    var originDis = (chestPos - handIKPos).magnitude; 
    handIKPos = chestPos + dir * originDis;
    animator.SetIKPosition(handIKGoal, handIKPos + animTransform.up * animator.deltaPosition.y);
    animator.SetIKPositionWeight(handIKGoal, 1);
}

尾聲

還是再次宣告一下,這些調整策略都是經驗之談,一定還有更好的調整方式。而且追求更高質量的IK或更多部位IK的調整,可以使用商店外掛,或者Unity包裡的Animator Rigging。本文就當拋磚引玉了捏!(´▽`)

相關文章