人形动画常见IK的处理
Unity中常见人形动画IK的处理方式
本文将尝试仅使用Untiy内置的Animator来解决常见的几种运动所需的IK。也会给出核心功能的代码实现。
效果一览:b站视频
Unity中人形角色的IK
IK(inverse kinematics)也就是逆运动学,在工业机器人领域,人们关注的逆运动学问题就是通过末端执行器的位姿来求解对应的关节变量。而在游戏中也类似,我们关注的就是根据末端肢体的位姿来调整身体其它部分的位置,好在Unity已经帮我们解决了这个复杂的求解过程。对于使用Avatar的人形动画,Unity内置的Animator允许我们调整5个部位的IK:头、左手、右手、左脚、右脚 (被封印的艾克佐迪亚。总的来说,我们只要设置好这些IK的位置和旋转就可以了,Unity会自动调整角色的骨骼。
![image](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240719202347147-1621161246.png)
PS:左脚、右脚、左手、右手的IK设置可以通过Animator.SetIKPosition等系列函数,通过AvatarIKGoal的枚举来选择部位;而头部则通过Animator.SetIKPosition等系列函数来控制。
这些函数要在OnAnimatorIK生命周期函数中调用才奏效
![image](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240719201706141-490933150.png)
![image](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240719202021197-1039829159.png)
站立、奔跑IK
这应该是人形角色最常规的IK了,通常的站立、奔跑、行走等动画都默认是在水平地面上的。但实际游戏地形会复杂很多,我们就需要调节足部的IK来贴合不同的地面。
![image](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240719205013461-1122598520.png)
1. 接触面法线
首先要做的就是通过物理检测找到「落脚点」,简单的射线检测就可以做到,射线检测返回的RaycastHit参数会告诉我们接触点和接触点的法线,以此就可以来调整脚的位置与姿态。
![image](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240719205429029-1757724132.png)
- 通过animator.GetBoneTransform得到脚部骨骼的Transform,进而得到脚部骨骼position。从该位置上方一段距离开始,向下检测接触面。人形角色通常是胶囊体,所以迈步时,脚很有可能就超出了胶囊体范围,而脚本身又没有碰撞体,就容易进入碰撞体内部,这时如果只是从脚本身开始检测就会检测失败,所以从上方开始检测。
![image](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240719210158454-704931762.png)
![image](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240719205658801-638318877.png)
/// <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](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240719212508883-1436065734.png)
![image](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240719211650623-843200428.png)
这也是上一步中,要用别扭的方法移动脚部的原因。这样我们就能算得哪只脚触碰接触面所需要移动的距离较大了,我们就将较大的这个偏移量同步应用到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](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240719220427667-631311334.gif)
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](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240719230215031-1155838730.png)
/// <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](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240719235957315-755756451.png)
![image](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240720000308549-1024852069.png)
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](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240719232926419-513469212.png)
![image](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240719233107583-488384280.png)
具体思路就是四肢向内侧方向进行检测。而且要多段检测,也就是将射线起点向前移动几次,能更好贴合V形角(就算没有刻意的V形墙面,当角色爬过外墙角时也会变成面向V形角的情况)
![image](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240719235413833-1458829913.png)
我们对步骤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](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240720002126879-904087653.png)
![image](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240720002251519-1707583844.png)
1. 头部朝向
头部的处理,我倒是比较简单。因为我的角色会转身,所以头部只需要调整俯仰角就可以了。而头部朝向不一定要百分百朝着瞄准点,看着像个样子就差不多,所以我的选择是——看向手里武器:
public void HeadLookAt(Vector3 weaponPos, float weight)
{
animator.SetLookAtPosition(weaponPos);
animator.SetLookAtWeight(weight);
}
2. 手臂朝向
调整手臂朝向的一大难点是保持手部姿势,直接设置朝向容易破坏持械姿势。
![image](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240720003857961-1696576953.png)
我的想法是:让双手IK的上下活动限制在一个球面上,这样一来,无论双臂朝向何方手臂伸展的距离都不会变化,这样就能保证动画的姿势维持。
![image](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240720004626020-1219602719.png)
![image](https://img2023.cnblogs.com/blog/3306371/202407/3306371-20240720003208792-1689261142.png)
至于这个球心位置,我是简单地选择角色胸骨骼位置,效果还行,动作变形程度不会很大(也可能是因为角色拿着手枪的原因)
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。本文就当抛砖引玉了捏!(´▽`)