目錄
Unity中人形角色的IKI
站立、奔跑IK
1. 接觸面法線
2. 調整質心位置
3. 保持原本朝向
攀爬IK
1. 四肢貼合
2. 保持身體與攀爬面的距離
3. 適應外拐角
瞄準IK
1. 頭部朝向
2. 手臂朝向
尾聲
本文將嘗試僅使用Untiy內置的Animator來解決常見的幾種運動所需的IK。也會給出核心功能的代碼實現。
Unity中人形角色的IKI
逆運動學(Inverse Kinematics,簡稱IK)在工業機器人領域是指通過末端執行器的位姿來求解相應關節變量的過程。游戲開發中也存在類似需求,即根據末端肢體的位姿來調整身體其他部位的位置。值得慶幸的是,Unity引擎已經幫我們處理了這個復雜的計算過程。
對于使用Avatar的人形角色動畫,Unity內置的Animator系統支持調整5個關鍵部位的IK:頭部、左手、右手、左腳和右腳。開發者只需設置好這些部位的IK位置和旋轉參數,Unity就會自動計算并調整角色的骨骼運動。
PS:左腳、右腳、左手、右手的IK設置可以通過Animator.SetIKPosition等系列函數,通過AvatarIKGoal的枚舉來選擇部位;而頭部則通過Animator.SetIKPosition等系列函數來控制。
這些函數要在OnAnimatorIK生命周期函數中調用才奏效
站立、奔跑IK
這應該是人形角色最常規的IK了,通常的站立、奔跑、行走等動畫都默認是在水平地面上的。但實際游戲地形會復雜很多,我們就需要調節足部的IK來貼合不同的地面。
1. 接觸面法線
首先要做的就是通過物理檢測找到「落腳點」,簡單的射線檢測就可以做到,射線檢測返回的RaycastHit參數會告訴我們接觸點和接觸點的法線,以此就可以來調整腳的位置與姿態。
- 通過animator.GetBoneTransform得到腳部骨骼的Transform,進而得到腳部骨骼position。從該位置上方一段距離開始,向下檢測接觸面。人形角色通常是膠囊體,所以邁步時,腳很有可能就超出了膠囊體范圍,而腳本身又沒有碰撞體,就容易進入碰撞體內部,這時如果只是從腳本身開始檢測就會檢測失敗,所以從上方開始檢測。
csharp
/// <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. 調整質心位置
光調整腳的位置是不夠的,因為這樣容易出現一只腳夠得著平面,但另一只則?「虛空接觸」?的情況(左側就是沒調整的,右側就是調整后的):
這也是上一步中,要用別扭的方法移動腳部的原因。這樣我們就能算得哪只腳觸碰接觸面所需要移動的距離較大了,我們就將較大的這個偏移量同步應用到animator.bodyPositon就可以了!
csharp
/// <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); }
你可能注意到了,質心調整并不一定要時時開啟,否則像快速上樓梯等斜面變化頻繁的情況,可能會劇烈抖動:
3. 保持原本朝向
我們希望足部在調整后仍能保持動畫原本的偏航角,(也就是說該外八的還是外八,內八的還是內八;而如果像這么做的話,就會導致腳筆直朝向玩家前方:
csharp
iKGoalRot = iKGoalRotations[leftFoot_Int] * animator.transform.rotation; animator.SetIKRotation(AvatarIKGoal.LeftFoot, iKGoalRot);
顯然,問題就出在我們是基于animator.transform.rotation來調整的。所以我們應該在真正調整朝向前,先記錄腳部IK原本的朝向,再在記錄下的這個朝向上應用步驟1中得到的「貼合地面的旋轉」。
csharp
private void MoveFeetToIKPos(AvatarIKGoal iKGoal, int iKGoal_Int) {//真正調整前,先記錄原本IK的位置和朝向var animTransform = animator.transform;var iKGoalPos = animator.GetIKPosition(iKGoal);var iKGoalRot = animator.GetIKRotation(iKGoal);//如果FixedUpdate中沒有檢測到信息就不更新IKif(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的位置,然后沿角色后方遠離一段距離作為射線檢測的起點,往角色的前方進行檢測。如下圖所示(紅色端為射線起點)
csharp
/// <summary> /// 通過射線檢測調整攀爬時四肢IK位置、旋轉,并將結果存儲在數組中 /// </summary> private void LimbsClimb_Solver(int iKGoal_Int, LayerMask climbMask) {var animTransform = animator.transform;//這里假設在攀爬系統的作用下,角色總能面朝攀爬面,故用forwardvar 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;} }
「遠離一段距離」還有其它好處,比如貼合這種上沿或者內拐角:
2. 保持身體與攀爬面的距離
讓角色的身體與墻面保持一定距離,可以讓動畫看起來更順眼。因為這位置只和墻面有關,所以調整起來也很簡單(需要用到攀爬法線climbNormal):
csharp
/// <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中的前向射線檢測會撲空。我們需要從兩側向中間檢測。
具體思路就是四肢向內側方向進行檢測。而且要多段檢測,也就是將射線起點向前移動幾次,能更好貼合V形角(就算沒有刻意的V形墻面,當角色爬過外墻角時也會變成面向V形角的情況)
我們對步驟1中的函數進行補充:
csharp
/// <summary> /// 通過射線檢測調整攀爬時四肢IK位置、旋轉,并將結果存儲在數組中 /// </summary> private void LimbsClimb_Solver(int iKGoal_Int, LayerMask climbMask) {var animTransform = animator.transform;//這里假設在攀爬系統的作用下,角色總能面朝攀爬面,故用forwardvar 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
第三人稱射擊游戲的瞄準,需要讓玩家的頭能朝向瞄準的地方,玩家拿槍的手也指向瞄準的地方。
1. 頭部朝向
頭部的處理,我倒是比較簡單。因為我的角色會轉身,所以頭部只需要調整俯仰角就可以了。而頭部朝向不一定要百分百朝著瞄準點,看著像個樣子就差不多,所以我的選擇是——看向手里武器:
csharp
public void HeadLookAt(Vector3 weaponPos, float weight) {animator.SetLookAtPosition(weaponPos);animator.SetLookAtWeight(weight); }
2. 手臂朝向
調整手臂朝向的一大難點是保持手部姿勢,直接設置朝向容易破壞持械姿勢。
我的想法是:讓雙手IK的上下活動限制在一個球面上,這樣一來,無論雙臂朝向何方手臂伸展的距離都不會變化,這樣就能保證動畫的姿勢維持。
至于這個球心位置,我是簡單地選擇角色胸骨骼位置,效果還行,動作變形程度不會很大(也可能是因為角色拿著手槍的原因)
csharp
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。本文就當拋磚引玉了捏!(′▽`)