
Inverse Kinematics and Foot Locking: From Two-Bone IK to Offline Slide Removal
A practical recipe for foot sliding: solve the leg with soft-clamped two-bone IK and toe orientation, lock the toe target during contacts with inertialization, annotate contacts automatically, then use a position-based dynamics-style offline pass to distribute corrections. The key insight is velocity preservation, not sticking to the ground, and the toe—not the heel—is what should be locked.
Foot sliding affects almost every animation system. Proper foot locking and inverse kinematics are hard to get right, but when they work, the improvement in animation quality is immediate. In this article I'm collecting the recipes I have used over the years into one place.
Two animation programmers will often give you two different answers to the same foot-sliding problem. This is more art than science. The goal here is not a canonical solution, but a practical foundation: solve the leg cleanly, lock the toe during contact, label contacts automatically, and use an offline constraint solver when you have the whole clip available.
Solving a Leg Chain
The basic setup is a posed leg chain. We want to change only the local rotations so that the original pose is preserved as much as possible, while the toe arrives at a desired location.
The pipeline is:
- Compute the target heel location from the target toe location.
- Solve the two-joint IK problem to place the heel at its target.
- Rotate the heel joint to orient the toe toward its target.
- Optionally rotate the toe end to avoid ground collisions.
Find the Heel Target
Given the current pose, the offset from toe to heel is known. Add that offset to the desired toe target:
*targetHeel = Vector3Add(*targetToe, Vector3Subtract(
globalTransforms[heelBoneIndex].translation,
globalTransforms[toeBoneIndex].translation));
Solve for the Heel Target
Next, solve the hip and knee rotations so the heel reaches its target. I use a modified version of my earlier two-bone IK code. The important additions are a soft extension clamp and a stable rotation axis derived from the knee side vector.
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);
}
The soft clamp matters. Instead of snapping to a maximum limb length, the target approaches maxExtension exponentially. This prevents hyper-extension without destroying the animation.
The side vector gives a stable axis around which to rotate the knee. That removes the need for a separate pole vector.
Solve for the Toe Target
Once the heel is solved, orient the heel joint so the toe points toward its target:
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);
Handle Ground-Plane Collisions
Targets can also be constrained against the ground. First record heel and toe heights in the bind pose:
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);
After clamping, re-orient the toe end so its tip does not penetrate the floor:
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);
Complete Leg Solver
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);
}
This deliberately recomputes global transforms more often than necessary. In production, you only need to update the bones consumed by the next stage. The solver also assumes a flat ground plane at zero height; on terrain, use a raycast.
The important result is a stable black box: given a pose and a toe target, it returns a plausible leg pose. Foot locking then becomes the problem of providing a good toe target.
Runtime Foot Locking
During a contact, follow a fixed floor point. Without a contact, follow the toe position from the source animation. Transition between the two sources with inertialization.
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;
}
Then feed that target into the leg solver:
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 });
}
Contact Times
To lock the foot, you need contact labels. Manual labeling is the gold standard, but velocity and height heuristics get you most of the way.
Start with the global velocity magnitude of the toe:
Toe height is less reliable than velocity, but it is a useful sanity check to reject stationary-but-airborne poses:
In good locomotion data, a velocity threshold between roughly 0.1 and 0.5 m/s and a height threshold around 0.1 m are sensible starting points.
I usually apply a majority-vote filter next. It removes single-frame activations and deactivations:
Then optionally smooth the binary signal into a continuous one:
This is not foolproof. Running contacts can last only one or two frames at 30 Hz. Keeping animation data at 60 Hz, and using cubic interpolation when upsampling, makes automatic labeling much easier.
Offline Foot Locking
If the whole animation is available, formulate foot sliding as constraints and solve them with a position-based-dynamics-like iteration.
Treat pelvis and toe positions as particles. Preserve their source-animation relations with soft springs, but add hard constraints that bring in-contact toe particles across adjacent frames together.
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++)
{
// Enforce inter-frame and in-frame constraints here.
}
}
For the left leg, inter-frame constraints look like this:
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);
}
Then add an in-frame constraint that preserves the hip-to-toe length:
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);
After all iterations, feed the corrected pelvis and toe targets back into SolveLegChain for every frame:
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);
}
Compare root motion scaled up by 1.25:
And root motion scaled down by 0.75:
Because the offline pass sees the entire clip, it distributes the correction instead of reacting to contacts frame by frame.
Philosophy and Pitfalls
Foot Sliding Is About Velocity
Foot sliding is a velocity mismatch between the source animation and the runtime character. It can come from the root moving at a different speed than the animation, or from blending and modifying local joint rotations.
Do not think of it primarily as friction or physics. That framing leads people to label contacts by height and then over-constrain the whole foot. The more useful model is velocity preservation, especially at contacts because that is where the error is easiest to see.
Lock the Toe, Not the Heel
In locomotion, the toe area is in contact most of the time. Heel-only contact is short and often unstable. Toe-only contact is common, and characters pivot around the toe.
Do not lock the heel just because it penetrates the ground in a bad pose. Give the heel freedom and constrain the toe.
IK Is a Modification, Not a Replacement
Two-joint IK is often imagined as a procedural replacement for three joints. That is useful in rigging, but for foot locking it destroys too much source detail. Treat IK as a minimal modification of an existing pose.
Avoid the Dinosaur
Real legs often run very close to hyper-extension. Pulling hips down removes the IK failure but ruins the motion. Prefer a little sliding to a broken pose. Preserve the input motion velocity, and let the solver make only the minimum necessary change.
Source Code
The full source code accompanying the original article is available in the GenoView-InverseKinematics repository.
Source:Orange Duckhttps://theorangeduck.com/page/inverse-kinematics-foot-locking