Unity3d FootIK Better Hybrid IK(4)
前言:正如上一节所述,IK问题实际上是可以分成“IK Solver”与“IK Goal Solver”两个问题的。
现在,我们手上的“IK Solver”,有:
[*]Unity Animator 内置的IK系统——Humanoid OnAnimatorIK
[*]AnimationRigging 的两个IK解算器【SolveTwoBoneIK】、【SolveFABRIK】
[*]Final IK的IK Solver,比如Biped IK
我们现有的“IK Goal Solver”解决方案,有:
[*]RayCast + AnimationCurve 的简单实现
[*]Final IK 的基于速度预测
[*]假设我们实现了《刺客信条》的基于路径预测的方案
那么,最终的解决方案,是否可以通过上面的两两排列组合而来呢?答案自然是可以的。
一、 OnAnimatorIK with Velocity Prediction
花活儿就不弄了,我们就简单地依赖于【Unity Animator】的内置IK系统,使用【Final IK】基于速度预测的算法,来实现一版杂交解决方案。
由于算法99%是【Final IK】的,别人又是商业付费插件,以下源码权当学习使用(应该不会侵权吧)。
1. Leg Solver
出于分离【IK Solver】与【IK Goal Solver】的想法,我们定义一个数据交互的接口【IKGoalSolverInterface】便于其他的“IK Solver”快速访问数据。
using UnityEngine;
public interface IKGoalSolverInterface
{
public Vector3 IKOffset { get; } //计算出的IK Goal 位置偏移量
public Vector3 IKPosition { get; }//计算出的IK Goal 最终位置
public Quaternion RotationOffset { get; } //计算出的 IK Goal 旋转偏移
}
当然,现阶段这种写法纯粹是为了统一【Leg Solver】与【Pelvis Solver】的数据访问,存在冗余项。
然后就是定义【Leg Solver】,数据结构与算法基本参考【Final IK】的【RootMotion.FinalIK.Grounding.Leg】
using UnityEngine;
namespace MyHybridIK
{
public class MyLegSolver : IKGoalSolverInterface
{
public Vector3 IKPosition { get; private set; }
public Vector3 IKOffset => Vector3.zero;
public Quaternion RotationOffset { get; private set; }
public Transform footTransform;
public bool isGrounded;
public bool invertFootCenter = false;
public float m_IKOffset { get; private set; }
//Ray Cast Info
public RaycastHit heelHit { get; private set; }
public RaycastHit capsuleHit { get; private set; }
private MyGrounding grounding;
private Vector3 up;
private float lastTime, deltaTime;
private Vector3 lastPosition, transformPosition;
private float heightFromGround;
private Vector3 velocity;
//IK Calculation Paras
private Quaternion toHitNormal, r;
//IK Infos
private bool initiated;
private Vector3 capsuleStart;
private Vector3 capsuleEnd;
/// <summary>
/// 初始化GroundSolver
/// </summary>
/// <param name=&#34;grounding&#34;>Ground参数对象</param>
/// <param name=&#34;transform&#34;>目标足部骨骼的Transform</param>
public void Initiate(MyGrounding grounding, Transform transform)
{
this.grounding = grounding;
this.footTransform = transform;
up = Vector3.up;
IKPosition = transform.position;
RotationOffset = Quaternion.identity;
initiated = true;
lastPosition = transform.position;
lastTime = Time.deltaTime;
}
public void Process()
{
if (!initiated) return;
if (grounding.maxStep <= 0) return;
//Initiate Position;
transformPosition = footTransform.position;
//Time Calculate
deltaTime = Time.time - lastTime;
lastTime = Time.time;
if (deltaTime == 0f) return;
up = grounding.up;
heightFromGround = Mathf.Infinity;
// Calculating velocity
velocity = (transformPosition - lastPosition) / deltaTime;
lastPosition = transformPosition;
var prediction = velocity * grounding.prediction;
//质量区间,暂时不考虑
//if (grounding.footRadius <= 0) grounding.quality = Grounding.Quality.Fastest;
isGrounded = false;
//Best Quality Cast
{
heelHit = GetRaycastHit(invertFootCenter ? -grounding.GetFootCenterOffset() : Vector3.zero);
capsuleHit = GetCapsuleHit(prediction);
if (heelHit.collider != null || capsuleHit.collider != null) isGrounded = true;
SetFootToPlane(capsuleHit.normal, capsuleHit.point, heelHit.point);
}
float offsetTarget = stepHeightFromGround;
if (!grounding.rootGrounded) offsetTarget = 0f;
m_IKOffset = RootMotion.Interp.LerpValue(m_IKOffset, offsetTarget, grounding.footSpeed, grounding.footSpeed);
m_IKOffset = Mathf.Lerp(m_IKOffset, offsetTarget, deltaTime * grounding.footSpeed);
float legHeight = grounding.GetVerticalOffset(transformPosition, grounding.root.position);
float currentMaxOffset = Mathf.Clamp(grounding.maxStep - legHeight, 0f, grounding.maxStep);
m_IKOffset = Mathf.Clamp(m_IKOffset, -currentMaxOffset, m_IKOffset);
RotateFoot();
IKPosition = transformPosition - up * m_IKOffset;
float rW = grounding.footRotationWeight;
RotationOffset = rW >= 1 ? r : Quaternion.Slerp(Quaternion.identity, r, rW);
}
private RaycastHit GetRaycastHit(Vector3 offsetFromHeel)
{
RaycastHit hit = new RaycastHit();
Vector3 origin = transformPosition + offsetFromHeel;
if (grounding.overstepFallsDown)
{
hit.point = origin - up * grounding.maxStep;
}
else
{
hit.point = new Vector3(origin.x, grounding.root.position.y, origin.z);
}
hit.normal = up;
if (grounding.maxStep <= 0f) return hit;
Physics.Raycast(origin + grounding.maxStep * up, -up, out hit, grounding.maxStep * 2, grounding.layerMask, QueryTriggerInteraction.Ignore);
// Since Unity2017 Raycasts will return Vector3.zero when starting from inside a collider
if (hit.point == Vector3.zero && hit.normal == Vector3.zero)
{
if (grounding.overstepFallsDown)
{
hit.point = origin - up * grounding.maxStep;
}
else
{
hit.point = new Vector3(origin.x, grounding.root.position.y, origin.z);
}
}
return hit;
}
private RaycastHit GetCapsuleHit(Vector3 offsetFromHeel)
{
RaycastHit hit = new RaycastHit();
Vector3 f = grounding.GetFootCenterOffset();
if (invertFootCenter) f = -f;
Vector3 origin = transformPosition + f;
if (grounding.overstepFallsDown)
{
hit.point = origin - up * grounding.maxStep;
}
else
{
hit.point = new Vector3(origin.x, grounding.root.position.y, origin.z);
}
hit.normal = up;
// Start point of the capsule
capsuleStart = origin + grounding.maxStep * up;
// End point of the capsule depending on the foot&#39;s velocity.
capsuleEnd = capsuleStart + offsetFromHeel;
if (Physics.CapsuleCast(capsuleStart, capsuleEnd, grounding.footRadius, -up, out hit, grounding.maxStep * 2, grounding.layerMask, QueryTriggerInteraction.Ignore))
{
// Safeguarding from a CapsuleCast bug in Unity that might cause it to return NaN for hit.point when cast against large colliders.
if (float.IsNaN(hit.point.x))
{
hit.point = origin - up * grounding.maxStep * 2f;
hit.normal = up;
}
}
// Since Unity2017 Raycasts will return Vector3.zero when starting from inside a collider
if (hit.point == Vector3.zero && hit.normal == Vector3.zero)
{
if (grounding.overstepFallsDown)
{
hit.point = origin - up * grounding.maxStep;
}
else
{
hit.point = new Vector3(origin.x, grounding.root.position.y, origin.z);
}
}
return hit;
}
private void SetFootToPlane(Vector3 planeNormal, Vector3 planePoint, Vector3 heelHitPoint)
{
planeNormal = RotateNormal(planeNormal);
toHitNormal = Quaternion.FromToRotation(up, planeNormal);
Vector3 pointOnPlane = RootMotion.V3Tools.LineToPlane(transformPosition + up * grounding.maxStep, -up, planeNormal, planePoint);
// Get the height offset of the point on the plane
heightFromGround = GetHeightFromGround(pointOnPlane);
// Making sure the heel doesn&#39;t penetrate the ground
float heelHeight = GetHeightFromGround(heelHitPoint);
heightFromGround = Mathf.Clamp(heightFromGround, -Mathf.Infinity, heelHeight);
}
private Vector3 RotateNormal(Vector3 normal)
{
return normal;
//return Vector3.RotateTowards(up, normal, grounding.maxFootRotationAngle * Mathf.Deg2Rad, deltaTime);
}
private float GetHeightFromGround(Vector3 hitPoint)
{
return grounding.GetVerticalOffset(transformPosition, hitPoint) - rootYOffset;
}
public float stepHeightFromGround
{
get
{
return Mathf.Clamp(heightFromGround, -grounding.maxStep, grounding.maxStep);
}
}
private float rootYOffset
{
get
{
return grounding.GetVerticalOffset(transformPosition, grounding.root.position - up * grounding.heightOffset);
}
}
private void RotateFoot()
{
// Getting the full target rotation
Quaternion rotationOffsetTarget = GetRotationOffsetTarget();
// Slerping the rotation offset
r = Quaternion.Slerp(r, rotationOffsetTarget, deltaTime * grounding.footRotationSpeed);
}
private Quaternion GetRotationOffsetTarget()
{
if (grounding.maxFootRotationAngle <= 0f) return Quaternion.identity;
if (grounding.maxFootRotationAngle >= 180f) return toHitNormal;
return Quaternion.RotateTowards(Quaternion.identity, toHitNormal, grounding.maxFootRotationAngle);
}
}
}
笔者才疏学浅,暂时无法把这段代码的核心算法抽丝剥茧地阐明原理,权当一个“黑匣子”来用吧。
通过【Leg Solver】,我们能基于速度,得到一个脚部的期望平面位置与旋转偏移,他最后的的输出就是IKPosition与 RotationOffset 两个变量。
2. Pelvis Solver
由于Pelvis的计算相对简单,咱就直接Copy-Paste了,代码来自【RootMotion.FinalIK.Grounding.Pelvis】
using UnityEngine;
namespace MyHybridIK
{
public class MyPelvisSolver : IKGoalSolverInterface
{
public Vector3 IKOffset { get; private set; }
public Vector3 IKPosition => Vector3.zero;
public Quaternion RotationOffset => Quaternion.identity;
public float heightOffset { get; private set; }
private MyGrounding grounding;
private Vector3 lastRootPosition;
private float damperF;
private bool initiated;
private float lastTime;
public void Initiate(MyGrounding grounding)
{
this.grounding = grounding;
initiated = true;
OnEnable();
}
public void Reset()
{
this.lastRootPosition = grounding.root.transform.position;
lastTime = Time.deltaTime;
IKOffset = Vector3.zero;
heightOffset = 0f;
}
public void OnEnable()
{
if (!initiated) return;
this.lastRootPosition = grounding.root.transform.position;
lastTime = Time.time;
}
public void Process(float lowestOffset, float highestOffset, bool isGrounded)
{
if (!initiated) return;
float deltaTime = Time.time - lastTime;
lastTime = Time.time;
if (deltaTime <= 0f) return;
float offsetTarget = lowestOffset + highestOffset;
if (!grounding.rootGrounded) offsetTarget = 0f;
// Interpolating the offset
heightOffset = Mathf.Lerp(heightOffset, offsetTarget, deltaTime * grounding.pelvisSpeed);
// Damper
Vector3 rootDelta = (grounding.root.position - lastRootPosition);
lastRootPosition = grounding.root.position;
// Fading out damper when ungrounded
damperF = RootMotion.Interp.LerpValue(damperF, isGrounded ? 1f : 0f, 1f, 10f);
// Calculating the final damper
heightOffset -= grounding.GetVerticalOffset(rootDelta, Vector3.zero) * grounding.pelvisDamper * damperF;
// Update IK value
IKOffset = grounding.up * heightOffset;
}
}
}
他最后的输出是IKOffset ,用于之后对Pelvis进行一定量的偏移。
3. Grounding
大体参考【RootMotion.FinalIK.Grounding】,这个类的作用是指定地面的一系列检测参数,计算Root的一些信息,用于提供给【Leg Solver】与【Pelvis Solver】进行运算。
唯一需要注意的是在使用时,需要手动给面板赋值LeftFootTransform与RightFootTransform,原版是通过【Biped IK】的Reference自动获取的。
using UnityEngine;
namespace MyHybridIK
{
public class MyGrounding : MonoBehaviour
{
public Transform root;
#region Needed Paras
public Transform leftFoot;
public Transform rightFoot;
public float maxStep = 0.5f;
public LayerMask layerMask;
public Vector3 up = Vector3.up;
public float footSpeed = 2.5f;
public float footRadius = 0.15f;
public float prediction = 0.05f;
public float footCenterOffset;
public float heightOffset;
public bool rootGrounded => rootHit.distance < maxStep * 2f;
public float footRotationWeight = 1;
public float footRotationSpeed = 7f;
public float maxFootRotationAngle = 45f;
public float pelvisSpeed = 5f;
public float pelvisDamper = 0.5f;
public float rootSphereCastRadius = 0.1f;
public bool isGrounded;
public float lowerPelvisWeight = 1f;
public float liftPelvisWeight;
public bool overstepFallsDown = true;
private bool initiated;
private RaycastHit rootHit;
#endregion
public MyLegSolver rightLegSolver;
public MyLegSolver leftLegSolver;
public MyPelvisSolver pelvisSolver;
private MyLegSolver[] legs;
private void Start()
{
root = this.transform;
Initate();
}
private void Initate()
{
rootHit = new RaycastHit();
//构建Leg
leftLegSolver = new MyLegSolver();
leftLegSolver.Initiate(this, leftFoot);
rightLegSolver = new MyLegSolver();
rightLegSolver.Initiate(this, rightFoot);
legs = new MyLegSolver;
legs = leftLegSolver;
legs = rightLegSolver;
//构建Pelvis
//....
pelvisSolver = new MyPelvisSolver();
pelvisSolver.Initiate(this);
initiated = true;
}
public void GroundingUpdate()
{
BetterGroundingProcessing();
}
private void BetterGroundingProcessing()
{
if (!initiated) return;
if (layerMask == 0) Debug.LogWarning(&#34;Grounding layers are set to nothing. Please add a ground layer.&#34;);
maxStep = Mathf.Clamp(maxStep, 0f, maxStep);
footRadius = Mathf.Clamp(footRadius, 0.0001f, maxStep);
pelvisDamper = Mathf.Clamp(pelvisDamper, 0f, 1f);
rootSphereCastRadius = Mathf.Clamp(rootSphereCastRadius, 0.0001f, rootSphereCastRadius);
maxFootRotationAngle = Mathf.Clamp(maxFootRotationAngle, 0f, 90f);
prediction = Mathf.Clamp(prediction, 0f, prediction);
footSpeed = Mathf.Clamp(footSpeed, 0f, footSpeed);
//Root Hit
rootHit = GetRootHit();
float lowestOffset = Mathf.NegativeInfinity;
float highestOffset = Mathf.Infinity;
isGrounded = false;
foreach (var leg in legs)
{
leg.Process();
if (leg.m_IKOffset > lowestOffset) lowestOffset = leg.m_IKOffset;
if (leg.m_IKOffset < highestOffset) highestOffset = leg.m_IKOffset;
if (leg.isGrounded) isGrounded = true;
}
// PelvisMove
lowestOffset = Mathf.Max(lowestOffset, 0f);
highestOffset = Mathf.Min(highestOffset, 0f);
pelvisSolver.Process(-lowestOffset * lowerPelvisWeight, -highestOffset * liftPelvisWeight, isGrounded);
}
private RaycastHit GetRootHit(float maxDistanceMlp = 10f)
{
RaycastHit h = new RaycastHit();
Vector3 _up = up;
Vector3 legsCenter = Vector3.zero;
legsCenter += leftLegSolver.footTransform.position;
legsCenter += rightLegSolver.footTransform.position;
legsCenter /= (float)legs.Length;
h.point = legsCenter - _up * maxStep * 10f;
float distMlp = maxDistanceMlp + 1;
h.distance = maxStep * distMlp;
if (maxStep <= 0f) return h;
Physics.SphereCast(legsCenter + _up * maxStep, rootSphereCastRadius, -up, out h, maxStep * distMlp, layerMask, QueryTriggerInteraction.Ignore);
return h;
}
public float GetVerticalOffset(Vector3 p1, Vector3 p2)
{
return p1.y - p2.y;
}
public Vector3 GetFootCenterOffset()
{
return root.forward * footRadius + root.forward * footCenterOffset;
}
}
}
方法逻辑就不一一解释了,咱也是一知半解。
4. RootMotionTool
迁移部分原【Final IK】的计算工具类代码。
namespace RootMotion {
public class Interp
{
public static float LerpValue(float value, float target, float increaseSpeed, float decreaseSpeed)
{
if (value == target) return target;
if (value < target) return Mathf.Clamp(value + Time.deltaTime * increaseSpeed, -Mathf.Infinity, target);
else return Mathf.Clamp(value - Time.deltaTime * decreaseSpeed, target, Mathf.Infinity);
}
}
public static class V3Tools
{
public static Vector3 LineToPlane(Vector3 origin, Vector3 direction, Vector3 planeNormal, Vector3 planePoint)
{
float dot = Vector3.Dot(planePoint - origin, planeNormal);
float normalDot = Vector3.Dot(direction, planeNormal);
if (normalDot == 0.0f) return Vector3.zero;
float dist = dot / normalDot;
return origin + direction.normalized * dist;
}
}
}
5. OnAnimatorIK
“IK Solver”层就很简单了,由于我们依赖的是OnAnimatorIK,直接使用Unity提供的接口进行IK Goal的权重,坐标的赋予就行了。
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using MyHybridIK;
public class BetterInnerIK : MonoBehaviour
{
public bool EnableIK;
public MyGrounding myGrounding;
private Animator m_animator;
public float pelvisWeight = 1;
public float leftFootPositionWeight = 1;
public float rightFootPositionWeight = 1;
public float leftFootRotationWeight = 1;
public float rightFootRotationWeight = 1;
public void Start()
{
m_animator = GetComponent<Animator>();
}
private void OnAnimatorIK(int layerIndex)
{
if (!EnableIK)
return;
myGrounding.GroundingUpdate();
PelvisProcess(myGrounding.pelvisSolver);
LeftIKProcess(myGrounding.leftLegSolver);
RightIKProcess(myGrounding.rightLegSolver);
}
private void PelvisProcess(IKGoalSolverInterface pelvisSolver)
{
m_animator.bodyPosition += pelvisSolver.IKOffset * pelvisWeight;
}
private void LeftIKProcess(IKGoalSolverInterface leftFootSolver)
{
m_animator.SetIKPositionWeight(AvatarIKGoal.LeftFoot, leftFootPositionWeight);
m_animator.SetIKRotationWeight(AvatarIKGoal.LeftFoot, leftFootRotationWeight);
var originalRotation = m_animator.GetIKRotation(AvatarIKGoal.LeftFoot);
var finalRotation = Quaternion.Slerp(Quaternion.identity, leftFootSolver.RotationOffset, leftFootRotationWeight) * originalRotation;
m_animator.SetIKRotation(AvatarIKGoal.LeftFoot, finalRotation);
m_animator.SetIKPosition(AvatarIKGoal.LeftFoot, leftFootSolver.IKPosition);
}
private void RightIKProcess(IKGoalSolverInterface rightFootSolver)
{
m_animator.SetIKPositionWeight(AvatarIKGoal.RightFoot, rightFootPositionWeight);
m_animator.SetIKRotationWeight(AvatarIKGoal.RightFoot, rightFootRotationWeight);
var originalRotation = m_animator.GetIKRotation(AvatarIKGoal.RightFoot);
var finalRotation = Quaternion.Slerp(Quaternion.identity, rightFootSolver.RotationOffset, leftFootRotationWeight) * originalRotation;
m_animator.SetIKRotation(AvatarIKGoal.RightFoot, finalRotation);
m_animator.SetIKPosition(AvatarIKGoal.RightFoot, rightFootSolver.IKPosition);
}
}
至此,【IK Goal Solver】的问题使用【Final IK】进行解决,【IK Solver】的问题交由【Humanoid Animator IK】解决,我们基本上实现了一版解决方案。
二、表现测试
走一遍Unity内置IK的设置流程:把模型动画设置成Humanoid、打开Animator Layer的IK Pass、挂载脚本等,最后,我们的角色实体大约长这样:
平平无奇小脚本
跑起来试试?
Cool,表现跟Final IK 相差无几
三、性能优化
1. Timeline统计
瞅一瞅Profiler的Timeline:
IK Goal Solver的用时几乎占了整个动画用时的一半
IK Sovler应该就是这个Job,用时反而很低
就如上一节我们所得出的结论,IK Goal Solver的耗时才是最大的那一环,真是恐怖如斯。
那么【Final IK】表现如何呢?配置一个使用【Final IK】的实体,看看表现:
总体0.084ms
SolverManager的处理是包含了ReadTransform,IK Solve,WriteTransform等等流程,一个实体总计0.08ms左右的用时,感觉跟用Unity的内置IK半斤八两。
把实体数弄到10个试试。
OnAnimatorIK的表现:
10个OnAnimatorIK串行执行
算上后面计算,的大约0.35ms消耗
Final IK的表现:
10个SolverManager串行执行
大约0.52ms消耗
这就有意思了,两种方法的耗时差值达到了0.2ms,为什么会出现这种情况?
实际上,由于我们的“IK Goal Solver”的算法基本一致,这一块的耗时两者其实是差不多的。
而“IK Solver”的算法,大家都是用三角函数解析,耗时也是相差无几。
真正出现差距的地方在那儿?还记得OnAnimatorIK的后面的逻辑块吗?
顾名思义,IK Solver Job
写入Transform的Job
非常朴素的猜想就是,OnAnimatorIK只是提供“IK Goal Solver”的接口,决定好了IK Goal的旋转位置后,交由【Animators.IKAndTwistBoneJob】与【Animators.WriteJob】执行多线程统一计算、写入。而事实也正是如此:
看见那个“over 12 threads”了吗
多线程Job也显示了他就是采用了这种策略
而【Final IK】呢,那就是完全的串行计算了,算一个写一个,产生了0.2ms的差距。
2. 并行化
基于上述分析,很明显的一点,如果我们能把OnAnimator的计算使用多线程并行计算(比如JobSystem),可想而知的用时会大幅减少。
换句话说,本来就应该把复数实体的“IK Goal Solver”问题给并行化计算,因为他们的计算完全独立,相互之间不存在任何依赖。
上面那种10个Solver的串行表现看着就很傻啊。
3. 优化Physics检测
当然,这是复数个实体的情况,那么单个实体的性能怎么提升呢?
虽然从现在的表现来看,一个实体的cost顶天0.1ms的消耗,但是我们就是不满意,就是想降低,有没有什么办法呢?
在【Final IK】插件的【Grounder Biped】上,有一个“Quality”选项:
Quality = Best
把他改成“Fast”,看看对比。
使用“Fast”
使用“Best”
提升很明显,而在代码中,Fast使用“RayCast”代替了“SphereCast”,舍弃了最耗时的“CapsuleCast”,牺牲了部分准确度换来了性能提升。
4. 颗粒度细分
这一节纯属笔者臆想。
现阶段实现的FootIK,在所有的动画状态都使用一套算法。有没有一种可能,在idle静止状态时,可以不用Prediction,就简单地检测地面;在Walk/Running时,再使用Prediction算法;而在某些待机动画中,则完全禁止IK(比如《原神》的角色在进行待机动画时,是完全禁止了IK的)。
又比如玩家角色和AI角色使用不同的IK算法,又或者进行视距剔除之类的balabala。
嗯,只能说深究下去,优化方向还是蛮多的hhh。
四、小结
终于把IK的本质问题给弄懂了,也着手实现了各种IK方案(虽然核心算法是抄Final IK)的,只是没有完全弄懂Final IK的预测算法有点遗憾。
如果可以,我真希望IK的研究就此告一段落,呜呜呜。
页:
[1]