
逆运动学与脚部锁定:从两骨 IK 到离线滑步修复
这篇文章给出脚部滑步的可执行修复链路:先用带软限位的两骨 IK 与脚尖朝向求解,再用惯性化在接触期锁住脚尖目标,然后自动标注接触帧,最后用 position-based dynamics 式离线求解分摊修正。作者强调核心不是把脚贴地,而是保持源动画速度;真正要锁的是脚尖,不是脚跟。
脚部滑步几乎会影响所有动画系统。正确的脚部锁定与逆运动学很难调好,但一旦生效,最终动画质量的提升会立刻看得见。这篇文章把作者多年来使用的几套方法整理到一起。
两个动画程序员面对同一个滑步问题,往往会给出两种不同答案。这个领域更像手艺而不是公式。这里的目标不是给出唯一标准方案,而是一个可落地的起点:先把腿解算干净,再在接触期锁住脚尖,然后自动标注接触帧,最后在拥有完整动画片段时用离线约束求解器分摊修正。
解算腿部关节链
基础设置是一条已经摆好姿势的腿部关节链。我们只想修改局部旋转,让原始姿势尽量保留,同时把脚尖送到目标位置。
流程如下:
- 根据脚尖目标计算脚跟目标。
- 解算双关节 IK,把脚跟放到目标位置。
- 旋转脚跟关节,让脚尖指向目标。
- 可选地修正脚尖末端,避免穿入地面。
计算脚跟目标
在当前姿势下,脚尖到脚跟的偏移已知。把这个偏移加到脚尖目标上即可:
*targetHeel = Vector3Add(*targetToe, Vector3Subtract(
globalTransforms[heelBoneIndex].translation,
globalTransforms[toeBoneIndex].translation));
解算脚跟目标
接下来解算髋关节和膝关节,使脚跟到达目标。这里用的是作者早期两骨 IK 代码的修改版,关键改动是软限位和由膝关节侧向量导出的稳定旋转轴。
static inline Quaternion QuaternionExp(Vector3 v)
{
float halfangle = sqrtf(v.x * v.x + v.y * v.y + v.z * v.z);
if (halfangle < 1e-4f)
{
return QuaternionNormalize((Quaternion){ v.x, v.y, v.z, 1.0f });
}
else
{
float c = cosf(halfangle);
float s = sinf(halfangle) / halfangle;
return (Quaternion){ s * v.x, s * v.y, s * v.z, c };
}
}
static inline Quaternion QuaternionFromScaledAngleAxis(Vector3 v)
{
return QuaternionExp(Vector3Scale(v, 0.5f));
}
static inline void TwoBoneInverseKinematics(
Quaternion *localHip,
Quaternion *localKnee,
Transform globalPelvis,
Transform globalHip,
Transform globalKnee,
Transform globalHeel,
Vector3 targetHeel,
Vector3 sideVector,
float maxExtension,
float softening)
{
Vector3 targetClamp = targetHeel;
float targetLength = Vector3Distance(targetHeel, globalHip.translation);
if (targetLength > maxExtension - softening)
{
float saturation = 1.0f - expf(
-Max(targetLength - maxExtension + softening, 0.0f) / softening);
targetClamp = Vector3Add(
globalHip.translation,
Vector3Scale(Vector3Subtract(targetHeel, globalHip.translation),
(maxExtension - softening + softening * saturation) / targetLength));
}
Vector3 axisDwn = Vector3Normalize(
Vector3Subtract(globalHeel.translation, globalHip.translation));
Vector3 axisFwd = Vector3Normalize(Vector3CrossProduct(axisDwn, sideVector));
Vector3 axisRot = Vector3Normalize(Vector3CrossProduct(axisDwn, axisFwd));
Vector3 a = globalHip.translation;
Vector3 b = globalKnee.translation;
Vector3 c = globalHeel.translation;
Vector3 t = targetClamp;
float lab = Vector3Distance(b, a);
float lcb = Vector3Distance(b, c);
float lat = Vector3Distance(t, a);
float lca = Vector3Distance(a, c);
float acab0 = acosf(Clamp(Vector3DotProduct(
Vector3Scale(Vector3Subtract(c, a), 1.0f / lca),
Vector3Scale(Vector3Subtract(b, a), 1.0f / lab)), -1.0f, +1.0f));
float babc0 = acosf(Clamp(Vector3DotProduct(
Vector3Scale(Vector3Subtract(a, b), 1.0f / lab),
Vector3Scale(Vector3Subtract(c, b), 1.0f / lcb)), -1.0f, +1.0f));
float acab1 = acosf(Clamp(
(lab * lab + lat * lat - lcb * lcb) / (2.0 * lab * lat),
-1.0f, +1.0f));
float babc1 = acosf(Clamp(
(lab * lab + lcb * lcb - lat * lat) / (2.0 * lab * lcb),
-1.0f, +1.0f));
Quaternion r0 = QuaternionFromScaledAngleAxis(
Vector3Scale(axisRot, acab1 - acab0));
Quaternion r1 = QuaternionFromScaledAngleAxis(
Vector3Scale(axisRot, babc1 - babc0));
Quaternion r2 = QuaternionNormalize(QuaternionBetween(
Vector3Subtract(globalHeel.translation, globalHip.translation),
Vector3Subtract(targetClamp, globalHip.translation)));
*localHip = QuaternionMultiply(QuaternionMultiply(QuaternionMultiply(
QuaternionInvert(globalPelvis.rotation), r2), r0),
globalHip.rotation);
*localKnee = QuaternionMultiply(QuaternionMultiply(
QuaternionInvert(globalHip.rotation), r1),
globalKnee.rotation);
}
软限位很重要。目标不是瞬间贴到最大腿长,而是指数式逼近 maxExtension,这样能抑制过度伸直,同时不会破坏原始动画。
膝关节侧向量提供一个稳定的旋转轴,因此不需要额外引入 pole vector。
解算脚尖目标
脚跟解算完后,把脚跟关节旋转到让脚尖指向目标:
static inline Quaternion QuaternionBetween(Vector3 p, Vector3 q)
{
Vector3 c = Vector3CrossProduct(p, q);
Quaternion o = {
c.x, c.y, c.z,
sqrtf(Vector3DotProduct(p, p) * Vector3DotProduct(q, q))
+ Vector3DotProduct(p, q)
};
return QuaternionLength(o) < 1e-8f
? QuaternionFromAxisAngle((Vector3){ 1.0f, 0.0f, 0.0f }, PI)
: QuaternionNormalize(o);
}
static inline Quaternion BoneOrientTowards(
Transform boneParentTransform,
Transform boneTransform,
Transform boneChildTransform,
Vector3 target)
{
Quaternion desiredRotation = QuaternionMultiply(
QuaternionNormalize(QuaternionBetween(
Vector3Subtract(boneChildTransform.translation,
boneTransform.translation),
Vector3Subtract(target, boneTransform.translation))),
boneTransform.rotation);
return QuaternionMultiply(
QuaternionInvert(boneParentTransform.rotation),
desiredRotation);
}
ForwardKinematics(globalTransforms, localTransforms, model);
localTransforms[heelBoneIndex].rotation = BoneOrientTowards(
globalTransforms[kneeBoneIndex],
globalTransforms[heelBoneIndex],
globalTransforms[toeBoneIndex],
*targetToe);
处理地面碰撞
还可以把目标限制在地面上方。先记录绑定位姿中的脚跟与脚尖高度:
float heelMinHeight = globalTransforms[leftHeelBoneIndex].translation.y;
float toeMinHeight = globalTransforms[leftToeBoneIndex].translation.y;
float toeEndMinHeight = globalTransforms[leftToeEndBoneIndex].translation.y;
targetToe->y = Max(targetToe->y, toeMinHeight);
targetHeel->y = Max(targetHeel->y, heelMinHeight);
高度截断后,再修正脚尖末端朝向,避免脚尖穿地:
ForwardKinematics(globalTransforms, localTransforms, model);
*targetToeEnd = globalTransforms[toeEndBoneIndex].translation;
if (enableHeightClamp)
{
targetToeEnd->y = Max(targetToeEnd->y, toeEndMinHeight);
}
localTransforms[toeBoneIndex].rotation = BoneOrientTowards(
globalTransforms[heelBoneIndex],
globalTransforms[toeBoneIndex],
globalTransforms[toeEndBoneIndex],
*targetToeEnd);
完整的腿部解算器
static inline void SolveLegChain(
Model model,
Transform *localTransforms,
Transform *globalTransforms,
Vector3 target,
Vector3 *targetHeel,
Vector3 *targetToe,
Vector3 *targetToeEnd,
int pelvisBoneIndex,
int hipBoneIndex,
int kneeBoneIndex,
int heelBoneIndex,
int toeBoneIndex,
int toeEndBoneIndex,
bool enableHeightClamp,
bool enableHeelLookAt,
bool enableToeLookAt,
float heelMinHeight,
float toeMinHeight,
float toeEndMinHeight,
float softening,
Vector3 kneeSideVector)
{
*targetToe = target;
if (enableHeightClamp)
{
targetToe->y = Max(targetToe->y, toeMinHeight);
}
*targetHeel = Vector3Add(*targetToe, Vector3Subtract(
globalTransforms[heelBoneIndex].translation,
globalTransforms[toeBoneIndex].translation));
if (enableHeightClamp)
{
targetHeel->y = Max(targetHeel->y, heelMinHeight);
}
Vector3 sideVector = Vector3RotateByQuaternion(
kneeSideVector,
globalTransforms[kneeBoneIndex].rotation);
float maxExtension = Vector3Distance(
globalTransforms[hipBoneIndex].translation,
globalTransforms[heelBoneIndex].translation);
Quaternion modifiedHip, modifiedKnee;
TwoBoneInverseKinematics(
&modifiedHip,
&modifiedKnee,
globalTransforms[pelvisBoneIndex],
globalTransforms[hipBoneIndex],
globalTransforms[kneeBoneIndex],
globalTransforms[heelBoneIndex],
*targetHeel,
sideVector,
maxExtension,
softening);
localTransforms[hipBoneIndex].rotation = modifiedHip;
localTransforms[kneeBoneIndex].rotation = modifiedKnee;
if (enableHeelLookAt)
{
ForwardKinematics(globalTransforms, localTransforms, model);
localTransforms[heelBoneIndex].rotation = BoneOrientTowards(
globalTransforms[kneeBoneIndex],
globalTransforms[heelBoneIndex],
globalTransforms[toeBoneIndex],
*targetToe);
}
if (enableToeLookAt)
{
ForwardKinematics(globalTransforms, localTransforms, model);
*targetToeEnd = globalTransforms[toeEndBoneIndex].translation;
if (enableHeightClamp)
{
targetToeEnd->y = Max(targetToeEnd->y, toeEndMinHeight);
}
localTransforms[toeBoneIndex].rotation = BoneOrientTowards(
globalTransforms[heelBoneIndex],
globalTransforms[toeBoneIndex],
globalTransforms[toeEndBoneIndex],
*targetToeEnd);
}
ForwardKinematics(globalTransforms, localTransforms, model);
}
这里会重复计算全局变换。生产实现里只需要更新下一阶段会消费的骨骼。求解器也假设地面是高度为零的平面;如果是有起伏的地形,要换成射线检测。
最终得到的是一个稳定的黑盒:输入当前姿势和脚尖目标,输出一个合理的腿部姿势。脚部锁定的问题于是变成“如何给出好的脚尖目标”。
运行时脚部锁定
接触时跟随地面上的固定点;没有接触时跟随源动画里的脚尖位置;两个来源之间用惯性化过渡。
typedef struct FootLockingState
{
Vector3 position;
Vector3 velocity;
Vector3 inputPosition;
Vector3 inputVelocity;
Vector3 offsetPosition;
Vector3 offsetVelocity;
float time;
Vector3 contact;
bool locked;
} FootLockingState;
void UpdateFootLockingState(
FootLockingState *state,
Vector3 inputPosition,
bool inputContact,
float contactHeight,
float deltaTime,
float unlockDistance,
float lockDistance,
float blendTime)
{
state->inputVelocity = Vector3Scale(
Vector3Subtract(inputPosition, state->inputPosition),
1.0f / Max(deltaTime, 1e-8f));
state->inputPosition = inputPosition;
InertializeCubicUpdate(
&state->position,
&state->velocity,
&state->time,
state->locked ? state->contact : state->inputPosition,
state->locked ? Vector3Zero() : state->inputVelocity,
state->offsetPosition,
state->offsetVelocity,
deltaTime,
blendTime);
float inputDistance = Vector3Distance(state->position, state->inputPosition);
if (!state->locked && inputContact && inputDistance < lockDistance)
{
state->locked = true;
state->contact = state->inputPosition;
state->contact.y = contactHeight;
InertializeCubicTransition(
&state->offsetPosition,
&state->offsetVelocity,
&state->time,
state->inputPosition,
state->inputVelocity,
state->contact,
Vector3Zero(),
blendTime);
}
else if (state->locked && (!inputContact || inputDistance > unlockDistance))
{
state->locked = false;
InertializeCubicTransition(
&state->offsetPosition,
&state->offsetVelocity,
&state->time,
state->contact,
Vector3Zero(),
state->inputPosition,
state->inputVelocity,
blendTime);
}
}
void InertializeCubicUpdate(
Vector3 *position,
Vector3 *velocity,
float *time,
Vector3 inputPosition,
Vector3 inputVelocity,
Vector3 offsetPosition,
Vector3 offsetVelocity,
float deltaTime,
float blendTime)
{
float t = Clamp((*time + deltaTime) / Max(blendTime, 1e-8f), 0.0f, 1.0f);
float w0 = 2.0f * t * t * t - 3.0f * t * t + 1.0f;
float w1 = (t * t * t - 2.0f * t * t + t) * blendTime;
float w2 = (6.0f * t * t - 6.0f * t) / Max(blendTime, 1e-8f);
float w3 = 3.0f * t * t - 4.0f * t + 1.0f;
*position = Vector3Add(inputPosition, Vector3Add(
Vector3Scale(offsetPosition, w0),
Vector3Scale(offsetVelocity, w1)));
*velocity = Vector3Add(inputVelocity, Vector3Add(
Vector3Scale(offsetPosition, w2),
Vector3Scale(offsetVelocity, w3)));
*time = *time + deltaTime;
}
void InertializeCubicTransition(
Vector3 *offsetPosition,
Vector3 *offsetVelocity,
float *time,
Vector3 sourcePosition,
Vector3 sourceVelocity,
Vector3 destinationPosition,
Vector3 destinationVelocity,
float blendTime)
{
float t = Clamp(*time / Max(blendTime, 1e-8f), 0.0f, 1.0f);
float w0 = 2.0f * t * t * t - 3.0f * t * t + 1.0f;
float w1 = (t * t * t - 2.0f * t * t + t) * blendTime;
float w2 = (6.0f * t * t - 6.0f * t) / Max(blendTime, 1e-8f);
float w3 = 3.0f * t * t - 4.0f * t + 1.0f;
*offsetPosition = Vector3Add(
Vector3Scale(Vector3Subtract(sourcePosition, destinationPosition), w0),
Vector3Scale(Vector3Subtract(sourceVelocity, destinationVelocity), w1));
*offsetVelocity = Vector3Add(
Vector3Scale(Vector3Subtract(sourcePosition, destinationPosition), w2),
Vector3Scale(Vector3Subtract(sourceVelocity, destinationVelocity), w3));
*time = 0.0f;
}
然后把目标送入腿部解算器:
UpdateFootLockingState(
&leftLockState,
globalTransforms[leftToeBoneIndex].translation,
testContacts.leftContacts[animationFrame] > contactThreshold,
toeMinHeight,
deltaTime,
unlockDistance,
lockDistance,
lockBlendTime);
leftTarget = leftLockState.position;
if (enableInverseKinematics)
{
SolveLegChain(
genoModel,
modifyTransforms,
globalTransforms,
leftTarget,
&leftTargetHeel,
&leftTargetToe,
&leftTargetToeEnd,
pelvisBoneIndex,
leftHipBoneIndex,
leftKneeBoneIndex,
leftHeelBoneIndex,
leftToeBoneIndex,
leftToeEndBoneIndex,
enableHeightClamp,
enableHeelLookAt,
enableToeLookAt,
heelMinHeight,
toeMinHeight,
toeEndMinHeight,
softening,
(Vector3){ 1.0f, 0.0f, 0.0f });
}
接触时间标注
要做脚部锁定,必须有接触标签。人工标注是黄金标准,但速度和高度启发式已经能覆盖大部分情况。
先看脚尖的全局速度大小:
脚尖高度不如速度可靠,但可以排除“停在空中但速度接近零”的情况:
高质量运动数据里,速度阈值先从 0.1 到 0.5 m/s 之间试,高度阈值大约 0.1 m 是合理起点。
随后通常加一个多数投票滤波,避免单帧激活或取消:
再可选地把二值信号平滑成连续信号:
这不是万能方法。跑步接触在 30 Hz 下可能只剩一到两帧。保留 60 Hz 动画数据,并在上采样时用三次插值,会让自动标注容易很多。
离线脚部锁定
如果整段动画都可用,可以把滑步表达成约束,再用类似 position-based dynamics 的迭代求解。
把骨盆和脚尖位置看成粒子。用软弹簧保持它们相对源动画的关系;同时在相邻帧之间加硬约束,把处于接触状态的脚尖粒子拉到一起。
Vector3* pelvisLocations = RL_CALLOC(animation.frameCount, sizeof(Vector3));
Vector3* leftToeLocations = RL_CALLOC(animation.frameCount, sizeof(Vector3));
Vector3* rightToeLocations = RL_CALLOC(animation.frameCount, sizeof(Vector3));
for (int i = 0; i < animation.frameCount; i++)
{
pelvisLocations[i] =
animation.framePoses[i][pelvisBoneIndex].translation;
leftToeLocations[i] =
animation.framePoses[i][leftToeBoneIndex].translation;
rightToeLocations[i] =
animation.framePoses[i][rightToeBoneIndex].translation;
}
float softFactor = 0.05f;
float hardFactor = 0.9f;
int iterations = 25000;
for (int iteration = 0; iteration < iterations; iteration++)
{
for (int i = 0; i < animation.frameCount; i++)
{
// 在这里施加帧间与帧内约束。
}
}
左腿的帧间约束如下:
if (i > 0)
{
Vector3 restPrevToe =
animation.framePoses[i - 1][leftToeBoneIndex].translation;
Vector3 restCurrToe =
animation.framePoses[i - 0][leftToeBoneIndex].translation;
Vector3 restPrevHip =
animation.framePoses[i - 1][pelvisBoneIndex].translation;
Vector3 restCurrHip =
animation.framePoses[i - 0][pelvisBoneIndex].translation;
Vector3 consPrevToe = leftToeLocations[i - 1];
Vector3 consCurrToe = leftToeLocations[i - 0];
Vector3 consPrevHip = pelvisLocations[i - 1];
Vector3 consCurrHip = pelvisLocations[i - 0];
if (contacts.leftContacts[i - 1] > contactThreshold &&
contacts.leftContacts[i - 0] > contactThreshold)
{
Vector3 toeTarget = Vector3Lerp(
consPrevToe, consCurrToe, 0.5f);
toeTarget.y = toeMinHeight;
leftToeLocations[i - 1] = Vector3Lerp(
consPrevToe, toeTarget, hardFactor);
leftToeLocations[i - 0] = Vector3Lerp(
consCurrToe, toeTarget, hardFactor);
}
else
{
Vector3 prevToeTarget = Vector3Add(
consCurrToe, Vector3Subtract(restPrevToe, restCurrToe));
Vector3 currToeTarget = Vector3Add(
consPrevToe, Vector3Subtract(restCurrToe, restPrevToe));
prevToeTarget.y = Max(prevToeTarget.y, toeMinHeight);
currToeTarget.y = Max(currToeTarget.y, toeMinHeight);
leftToeLocations[i - 1] = Vector3Lerp(
consPrevToe, prevToeTarget, softFactor);
leftToeLocations[i - 0] = Vector3Lerp(
consCurrToe, currToeTarget, softFactor);
}
pelvisLocations[i - 1] = Vector3Lerp(
consPrevHip,
Vector3Add(consCurrHip,
Vector3Subtract(restPrevHip, restCurrHip)),
softFactor);
pelvisLocations[i - 0] = Vector3Lerp(
consCurrHip,
Vector3Add(consPrevHip,
Vector3Subtract(restCurrHip, restPrevHip)),
softFactor);
}
再加一个帧内约束,保持髋到脚尖的长度:
Vector3 restHip =
animation.framePoses[i][pelvisBoneIndex].translation;
Vector3 restToe =
animation.framePoses[i][leftToeBoneIndex].translation;
float restLength = Vector3Distance(restHip, restToe);
Vector3 currHip = pelvisLocations[i];
Vector3 currToe = leftToeLocations[i];
Vector3 currDirection = Vector3Normalize(
Vector3Subtract(currHip, currToe));
pelvisLocations[i] = Vector3Lerp(
currHip,
Vector3Add(currToe,
Vector3Scale(currDirection, +restLength)),
softFactor);
leftToeLocations[i] = Vector3Lerp(
currToe,
Vector3Add(currHip,
Vector3Scale(currDirection, -restLength)),
softFactor);
迭代完成后,把修正后的骨盆和脚尖目标逐帧送回 SolveLegChain:
for (int i = 0; i < animation.frameCount; i++)
{
BackwardKinematics(localTransforms, animation.framePoses[i], model);
localTransforms[pelvisBoneIndex].translation = pelvisLocations[i];
ForwardKinematics(globalTransforms, localTransforms, model);
SolveLegChain(
model,
localTransforms,
globalTransforms,
leftToeLocations[i],
&leftTargetHeel,
&leftTargetToe,
&leftTargetToeEnd,
pelvisBoneIndex,
leftHipBoneIndex,
leftKneeBoneIndex,
leftHeelBoneIndex,
leftToeBoneIndex,
leftToeEndBoneIndex,
enableHeightClamp,
enableHeelLookAt,
enableToeLookAt,
heelMinHeight,
toeMinHeight,
toeEndMinHeight,
softening,
leftKneeSideVector);
SolveLegChain(
model,
localTransforms,
globalTransforms,
rightToeLocations[i],
&rightTargetHeel,
&rightTargetToe,
&rightTargetToeEnd,
pelvisBoneIndex,
rightHipBoneIndex,
rightKneeBoneIndex,
rightHeelBoneIndex,
rightToeBoneIndex,
rightToeEndBoneIndex,
enableHeightClamp,
enableHeelLookAt,
enableToeLookAt,
heelMinHeight,
toeMinHeight,
toeEndMinHeight,
softening,
rightKneeSideVector);
ForwardKinematics(animation.framePoses[i], localTransforms, model);
}
先把根运动放大 1.25 倍来制造滑步,对比效果:
再把根运动缩小到 0.75:
离线方法能看到整段动画,所以它能把修正分摊到全局,而不是逐帧被动反应。
哲学与常见陷阱
滑步本质是速度问题
滑步是源动画速度与运行时角色速度之间的不一致。它可能来自根运动速度与动画不匹配,也可能来自混合或修改局部关节旋转。
不要把它主要理解成摩擦或物理。这个思路会让人们用高度来标注接触,然后过度约束整只脚。更有效的模型是速度保持,尤其是接触期,因为那里最容易看出误差。
要锁脚尖,不是锁脚跟
行走中,大多数时间是脚尖区域接触地面。只有脚跟接触的情况又短又不稳定;脚尖接触则非常常见,角色还经常绕脚尖旋转。
不要因为坏姿势里脚跟穿地就去锁定脚跟。给脚跟自由,去约束脚尖。
IK 是修正,不是替换
双关节 IK 常被想象成根据规则和 pole vector 替换三个关节。这在绑定里有用,但在脚部锁定里会破坏太多源动画细节。应该把它当成对现有姿势的最小修正。
避免恐龙膝
真实腿常运行在接近过度伸直的状态。把髋部拉下来虽然避免了 IK 失败,却破坏了动作。宁可接受一点滑步,也不要破坏姿势。保持输入运动的速度,让解算器只做必要的最小修改。
源码
原文完整代码在 GenoView-InverseKinematics 仓库。
原文来源:Orange Duckhttps://theorangeduck.com/page/inverse-kinematics-foot-locking