Skip to content
←返回博客
逆运动学与脚部锁定:从两骨 IK 到离线滑步修复
反向运动学Foot Locking动画

逆运动学与脚部锁定:从两骨 IK 到离线滑步修复

这篇文章给出脚部滑步的可执行修复链路:先用带软限位的两骨 IK 与脚尖朝向求解,再用惯性化在接触期锁住脚尖目标,然后自动标注接触帧,最后用 position-based dynamics 式离线求解分摊修正。作者强调核心不是把脚贴地,而是保持源动画速度;真正要锁的是脚尖,不是脚跟。

Daniel Holden2026年7月30日8 分钟阅读714次阅读
EN

脚部滑步几乎会影响所有动画系统。正确的脚部锁定与逆运动学很难调好,但一旦生效,最终动画质量的提升会立刻看得见。这篇文章把作者多年来使用的几套方法整理到一起。

两个动画程序员面对同一个滑步问题,往往会给出两种不同答案。这个领域更像手艺而不是公式。这里的目标不是给出唯一标准方案,而是一个可落地的起点:先把腿解算干净,再在接触期锁住脚尖,然后自动标注接触帧,最后在拥有完整动画片段时用离线约束求解器分摊修正。

解算腿部关节链

基础设置是一条已经摆好姿势的腿部关节链。我们只想修改局部旋转,让原始姿势尽量保留,同时把脚尖送到目标位置。

腿部关节链 IK 设置
腿部关节链、当前姿势,以及脚尖目标。

流程如下:

  1. 根据脚尖目标计算脚跟目标。
  2. 解算双关节 IK,把脚跟放到目标位置。
  3. 旋转脚跟关节,让脚尖指向目标。
  4. 可选地修正脚尖末端,避免穿入地面。

计算脚跟目标

在当前姿势下,脚尖到脚跟的偏移已知。把这个偏移加到脚尖目标上即可:

*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。

双骨 IK 放置脚跟,同时尽量保留原始姿势。

解算脚尖目标

脚跟解算完后,把脚跟关节旋转到让脚尖指向目标:

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 是合理起点。

随后通常加一个多数投票滤波,避免单帧激活或取消:

多数投票接触滤波
60 Hz 数据下,5 帧宽度是不错的默认值。

再可选地把二值信号平滑成连续信号:

高斯平滑后的接触信号
平滑让运行时更容易调节接触触发时机和灵敏度。

这不是万能方法。跑步接触在 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
传统绑定 IK 会替换姿势;运行时脚部 IK 应该修正姿势。

双关节 IK 常被想象成根据规则和 pole vector 替换三个关节。这在绑定里有用,但在脚部锁定里会破坏太多源动画细节。应该把它当成对现有姿势的最小修正。

避免恐龙膝

为了满足 IK 把髋部往下拉,会让膝盖弯成恐龙状。
腿部接近过度伸直
真实运动经常非常接近过度伸直。

真实腿常运行在接近过度伸直的状态。把髋部拉下来虽然避免了 IK 失败,却破坏了动作。宁可接受一点滑步,也不要破坏姿势。保持输入运动的速度,让解算器只做必要的最小修改。

源码

原文完整代码在 GenoView-InverseKinematics 仓库。


来源:Inverse Kinematics and Foot Locking, Orange Duck。

作为亚马逊联盟会员,我们可能从符合条件的购买中获得佣金。