逆運動学とフットロッキング
Inverse Kinematics and Foot Locking (theorangeduck.com)
要約
この記事は、アニメーションにおける逆運動学(IK)とフットロッキングの技術について解説しています。フットスライディングはアニメーションの品質に大きく影響する問題であり、著者はこの問題を解決するためのいくつかのレシピを紹介しています。具体的には、足のチェーンを目標位置に配置する方法、ランタイムでのフットロッキング、アニメーションデータへのコンタクト注釈付け、オフラインでのフットスライディング除去といった手法が説明されています。
全文翻訳
逆運動学とフットロッキング
2026/07/30
逆運動学とフットロッキングに関するブログ記事は、常に最も多くリクエストされるものの一つです。フットスライディングはほぼすべての С анимация システムに影響を与える問題であり、適切なフットロッキングと逆運動学は、正しく実装するのが難しいものの、結果として得られるアニメーションの視覚的な品質に大きなプラスの影響を与えることができます。
しかし、アニメーションプログラマーに2人話を聞けば、フットスライディングを解決する最善の方法について、それぞれ全く異なる答えを聞くことになるでしょう。間違いなく、このトピックは科学というより芸術に近いものであり、それがなぜリソースや学術的な分野で見つけにくいのか、そしてなぜ学術的な分野でそれほど注目されないのかを説明しているのかもしれません。
とはいえ、フットスライディングを解決するためのちょっとしたレシピのセレクションを共有したいと思います。これらがこの問題に対する証明済みの最善の解決策ではないことは確かですが(そして、よりうまくやっているゲームも数多くあるでしょう)、長年にわたって私自身に役立ってきたものであり、このトピックに入門する人にとっては良い出発点となるはずです。
消化しやすくするために、トピックをいくつかのパートに分けました。まず、つま先を所望の位置に配置するために脚のチェーンを解く方法を説明します。次に、イネルシャライゼーションを使用して、実行時に接触中のつま先の位置をロックするソリューションをカバーします。第三に、アニメーションデータ内の接触を自動的に注釈付けする技術。第四に、アニメーション全体とサイクルが利用可能な場合のフットスライディング除去のためのオフラインメソッド。そして最後に、この全体に関するいくつかの哲学的な考察で締めくくります。
記事全体を通してコードを提供しますが、この記事の完全なソースコードはこちらで見つけることができます。
脚のチェーンを解く
概要
問題のセットアップは以下の通りです。キャラクターの脚のジョイントチェーンがあり、特定の構成でポーズが取られています。ローカル回転を、既存のポーズを可能な限り維持しつつ、つま先が所望の位置に来るように変更したいと考えています。
[画像: setup]
これを実現するために、以下の手順を実行します。
- 目標つま先位置から目標かかと位置を計算する
- 2関節IK問題を解いて、かかとを目標位置に配置する
- かかとジョイントを回転させて、つま先が目標方向を向くようにする
- オプションで、地面との衝突を解決するために、つま先端を回転させる
かかとターゲットの検索
目標のかかと位置の計算は簡単です。既存の入力ポーズにおける、つま先からかかとへのベクトルを計算し、それを目標つま先に追加するだけです。
*targetHeel = Vector3Add(*targetToe, Vector3Subtract(
globalTransforms[heelBoneIndex].translation,
globalTransforms[toeBoneIndex].translation));
これは以下のようになります。
[画像: setup]
2. かかとターゲットの解決
かかとターゲットが得られたら、腰と膝のジョイントのローカル回転を解いて、脚チェーンを正しく配置することができます。これを行うために、以前共有した2関節逆運動学コードのわずかに変更されたバージョンを使用します(QuaternionFromScaledAngleAxis関数の導出については、このページを参照してください)。
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)
{
// Softly clamp the target based on the distance given by maxExtension
Vector3 targetClamp = targetHeel;
float targetLength = Vector3Distance(targetHeel, globalHip.translation);
if (targetLength > maxExtension - softening)
{
// Smoothly clamp when it gets within softening distance of the maxExtension
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));
}
// Compute the rotation axis based on vector perpendicular to the plane
// rotation which is closed to the provided knee side vector
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;
// Compute the change in rotation angle required using the cosine rule
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));
// Compute the three world-space rotations needed to solve the two-bone ik problem
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)));
// Update the local space rotations for the hip and the knee
*localHip = QuaternionMultiply(QuaternionMultiply(QuaternionMultiply(
QuaternionInvert(globalPelvis.rotation), r2), r0), globalHip.rotation);
*localKnee = QuaternionMultiply(QuaternionMultiply(
QuaternionInvert(globalHip.rotation), r1), globalKnee.rotation);
}
ここでの2つの追加点は単純です。まず、ユーザーが最大伸長長 maxExtension を提供できるようにし、それを softening 距離に基づいてソフトにクランプします。これにより、手足は提供された maxExtension に指数関数的に近づくだけになり、過度の伸長を防ぐのに役立ちます。
Vector3 targetClamp = targetHeel;
float targetLength = Vector3Distance(targetHeel, globalHip.translation);
if (targetLength > maxExtension - softening)
{
// Smoothly clamp when it gets within softening distance of the maxExtension
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));
localTransforms と globalTransforms の2つのバッファにボーンのローカルおよびグローバルトランスフォームが格納されていると仮定すると、関数は次のように呼び出すことができます。
// Solve Two-Bone Inverse Kinematics to place heel
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;
ここで、genoキャラクターの場合、kneeSideVector は単に (Vector3){ 1.0f, 0.0f, 0.0f } であり、softening の典型的な値は 0.005f(メートル)です。
これが完了すると、かかとがターゲットに配置されますが、適用したローカル回転により、つま先ジョイントはもはやターゲット方向を向いていません。
3. つま先ターゲットの解決
つま先ターゲットを解決するために、QuaternionBetween を使用した基本的なルックアットを実行できます(興味のある方は、この関数の導出について少し書きましたこちら)。
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);
そして、以下の動作が得られます。
4. 地面との衝突の処理
地面との衝突を処理するための良い追加機能があります。まず、バインドポーズでのかかととつま先の高さを取得する必要があります(globalTransforms がバインドポーズのトランスフォームで満たされていると仮定します)。
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);
}
// Find the Heel Target Location
*targetHeel = Vector3Add(*targetToe, Vector3Subtract(
globalTransforms[heelBoneIndex].translation,
globalTransforms[toeBoneIndex].translation));
if (enableHeightClamp)
{
targetHeel->y = Max(targetHeel->y, heelMinHeight);
}
// Solve Two-Bone Inverse Kinematics to place heel
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;
// Orient Toe towards Target
if (enableHeelLookAt)
{
ForwardKinematics(globalTransforms, localTransforms, model);
localTransforms[heelBoneIndex].rotation = BoneOrientTowards(
globalTransforms[kneeBoneIndex],
globalTransforms[heelBoneIndex],
globalTransforms[toeBoneIndex],
*targetToe);
}
// Orient Toe-End
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);
}
// Recompute final global transforms
ForwardKinematics(globalTransforms, localTransforms, model);
}
ここでは、ForwardKinematics 関数を使用して、ローカルトランスフォームを更新するたびにグローバルトランスフォームの完全なセットを再計算するという怠惰なことをしていることに気づくでしょう。しかし、現実には、次のステージで使用する予定のボーンのグローバルトランスフォームのみを再計算する必要があります。
同様に、ここでは地面がゼロにあると仮定して高さをクランプしていますが、動的な地形がある場合は、足元の実際の地面の高さを取得するためにレイキャストなどを行う必要があります。
しかし、この関数が最終的に私たちに与えるのは、キャラクターの既存のポーズと新しいつま先ターゲットを入力として与えることができ、常に比較的妥当な出力を得ることができる一種のブラックボックスです。これにより、フットロッキングの問題が大幅に簡素化されます。なぜなら、つま先ターゲットがスライドしないようにするだけでよく、結果がうまくいくと比較的確信できるからです。
フットロッキング
前のパートでは、逆運動学とその他のトリックを使用して脚のジョイントチェーンを解き、つま先ジョイントを所望の位置に配置する方法を学びました。
このパートでは、接触状態中のつま先をロックするための私のちょっとしたレシピを紹介します。その仕組みは非常にシンプルです。アクティブな接触がない場合は、入力ソースアニメーションのつま先の位置を追跡します。接触がアクティブな場合は、接触が最初にアクティブになったときのつま先の位置である、床上の静的な位置を追跡します。そして、これらの2つのソース間をイネルシャライゼーションを使用して遷移します。
フットロッキング設定の状態を追跡するために、多くの変数を管理する必要があります。各接触点について、以下の変数が必要になります。
typedef struct FootLockingState
{
Vector3 position; // Current Position
Vector3 velocity; // Current Velocity
Vector3 inputPosition; // Input Source Position
Vector3 inputVelocity; // Input Source Velocity
Vector3 offsetPosition; // Inertialization Offset
Vector3 offsetVelocity; // Inertialization Offset Velocity
float time; // Time since Inertialization transition
Vector3 contact; // Contact Location
bool locked; // If the contact is currently locked
} FootLockingState;
次に、各フレームで、この状態と入力ポーズでのつま先の位置を入力として受け取り、つま先ターゲットを更新する関数を記述できます。
void UpdateFootLockingState(
FootLockingState* state,
Vector3 inputPosition,
bool inputContact,
float contactHeight,
float deltaTime,
float unlockDistance,
float lockDistance,
float blendTime)
{
// Update Input State Position and Velocity via finite difference
state->inputVelocity = Vector3Scale(
Vector3Subtract(inputPosition, state->inputPosition),
1.0f / Max(deltaTime, 1e-8f));
state->inputPosition = inputPosition;
// Update Cubic Inertialization
InertializeCubicUpdate(
&state->position,
&state->velocity,
&state->time,
state->locked ? state->contact : state->inputPosition,
state->locked ? Vector3Zero() : state->inputVelocity,
state->offsetPosition,
state->offsetVelocity,
deltaTime,
blendTime);
// Check the distance of the input location from the locked state
float inputDistance = Vector3Distance(state->position, state->inputPosition);
if (!state->locked && inputContact && inputDistance < lockDistance)
{
// Lock if the input wants it the current state is within locking distanced
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))
{
// Unlock if the input wants to unlock or the distance is too far
state->locked = false;
InertializeCubicTransition(
&state->offsetPosition,
&state->offsetVelocity,
&state->time,
state->contact,
Vector3Zero(),
state->inputPosition,
state->inputVelocity,
blendTime);
}
}
ここで InertializeCubicUpdate と InertializeCubicTransition は、次のように定義された3次イネルシャライゼーション関数です。
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 = Vector3Subtract(Vector3Add(sourcePosition,
Vector3Add(Vector3Scale(*offsetPosition, w0),
Vector3Scale(*offsetVelocity, w1))), destinationPosition);
*offsetVelocity = Vector3Subtract(Vector3Add(sourceVelocity,
Vector3Add(Vector3Scale(*offsetPosition, w2),
Vector3Scale(*offsetVelocity, w3))), destinationVelocity);
*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 });
}
これは次のようになります。
しかし、入力アニメーションに接触時間のラベルをどのように付けるのでしょうか?さて、それについては別のちょっとしたレシピがあります。
接触時間
前のパートで作成したフットロッキングコードを使用するには、入力アニメーションのいつつま先が地面に接触しているべきか(したがって、スライドしないはずか)を知る必要があります。
通常、これはアニメーションデータに追加のメタデータとして追加したいものです。このような接触時間の注釈付けにおける唯一のゴールドスタンダードは、手動でラベル付けすることです。しかし、約90%のところまで到達するために、いくつかの簡単なヒューリスティックを使用できます。
一般的な考え方は、ソースアニメーションのつま先ジョイントのグローバル速度の大きさを調べることです。
[画像: toe velocity]
ここでは、速度が低く、足が地面に接触している時期が非常に明確に見えます。これは良いニュースです。この信号は、二値の接触ラベルを近似するものを取得するために、しきい値処理が比較的簡単であるはずです。
[画像: toe velocity threshold]
これは、つま先の高さよりもはるかに役立ちます。人々は移動中に足をあまり持ち上げない傾向があり、接触中でも高さが上下することがあります。これにより、つま先の高さをしきい値処理するのがはるかに難しくなります。
[画像: toe height]
しかし、足の高さは依然として良い sanity check になり、足が静止しているが空中に保持されているときの接触のラベル付けを回避できるため、必要に応じてこれらの信号の両方のしきい値処理を組み合わせることができます。
ほとんどの高品質なアニメーションデータでは、0.5 m/s から 0.1 m/s の範囲がかなり妥当な速度しきい値であり、0.1 m がかなり妥当な高さしきい値(もちろん、スケルトン上のつま先の配置によります)であることがわかります。
しきい値処理された信号に適用したいいくつかの後処理があります。
最初のものは多数決フィルターです。これは短いフレームのウィンドウをスライドさせ、中央のフレームの出力を決定するために多数決を取ります。このフィルターは、単一フレームのアクティベーションまたはディアクティベーションを回避するのに役立ちます(numpyでは、二値信号に対して同じことを行うメディアンフィルターを使用しています)。幅が5フレーム(60Hz)というのは良い開始点です。
[画像: majority vote filter]
時々、ガウス平滑化を適用することも好きです。これにより、二値信号が連続信号に変わり、実行時に接触アクティベーションにしきい値を設定できるようになり、感度と接触の開始または終了時期を調整するためのバッファ時間が得られます。
[画像: gaussian smoothing]
このヒューリスティックは完璧からは程遠く、ランニングアニメーション中に常に接触をきれいにラベル付けするのが難しいと感じています。ここでの問題の一部は、靴や足の変形が速いランニングでは比較的大きい可能性があることです(足はかなりの量上下に動く可能性があります)、そして接触時間は比較的短くなる可能性があります。
30Hzのランニング中の接触は、しばしば1〜2フレームしかないことがあります。これは、多数決フィルターと組み合わせると、わずかな動きでも見逃してしまう可能性があることを意味します。したがって、アニメーションデータを60Hzに保つことは、ここで大いに役立ちます。
そして、30Hzの信号からアップサンプリングしている場合、3次補間を使用して計算された速度は、線形補間を使用して計算された速度よりも、しきい値処理が容易になる傾向があります。したがって、自動接触注釈付けは、データの管理に注意を払うことが本当に報われる良い例です。
次のパートでは、オフラインでフットスライディングを解決するための代替方法について説明します。
オフラインフットロッキング
アニメーションクリップ全体にアクセスでき、いくつかのサイクルに余裕がある場合、イネルシャライゼーションを使用したランタイムでのアプローチとは全く異なるアプローチでフットスライディングを除去することが可能です。
フットスライディング問題を制約のセットとして定式化し、位置ベースダイナミクスのような方法でそれらの制約を解くことができると、より良い結果が得られることがわかりました。これは複雑に聞こえるかもしれませんが、その仕組みの背後にあるすべての理論を理解することはできますが、実際の С реализация は非常にシンプルです。
一般的な考え方は次のとおりです。各フレームの骨盤とつま先の位置を計算し、これらの位置すべてを接続された粒子のセットとして扱います。これらの粒子のフレーム間の相対位置と、フレーム内のこれらの粒子の相対位置が、ソフトなスプリングのような接続を使用して入力アニメーションに似ているようにしようとします。次に、接触していると見なされるつま先粒子に対して、フレーム間でゼロ距離を強制する追加のハード接続を追加します。これにより、接触時間中にすべてつま先粒子が効果的に「束ねられ」、この調整の効果がアニメーション全体にうまく分散されます。
これらの制約を強制する方法は、単純にアニメーション全体を何度もループし、各制約について、制約がよりよく満たされるように影響を受ける粒子をわずかに調整することです。最終的には、フットスライディングを示さない新しい骨盤とつま先のターゲットセットが得られ、それを脚ソルバーにフィードしてローカル回転を調整できます。
まず、入力アニメーションの左つま先、右つま先、および骨盤の位置を計算しましょう。これらは、制約を強制するために変更する配列です。
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++)
{
// TODO: Enforce constraints...
}
}
ループ内では、フレーム間制約を次のように定式化できます。
// Left Leg Inter-Frame Constraints
if (i > 0)
{
// Get the toe and hip positions in the input animation
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;
// Get the modified toe and hip positions
Vector3 consPrevToe = leftToeLocations[i - 1];
Vector3 consCurrToe = leftToeLocations[i - 0];
Vector3 consPrevHip = pelvisLocations[i - 1];
Vector3 consCurrHip = pelvisLocations[i - 0];
// If the contact is active on both previous and current frames
if (contacts.leftContacts[i - 1] > contactThreshold &&
contacts.leftContacts[i - 0] > contactThreshold)
{
// Compute the mid-point between the current contacts and set the
// height to the ground height
Vector3 toeTarget = Vector3Lerp(consPrevToe, consCurrToe, 0.5f);
toeTarget.y = toeMinHeight;
// Move the locations toward the mid-point
leftToeLocations[i - 1] = Vector3Lerp(consPrevToe, toeTarget, hardFactor);
leftToeLocations[i - 0] = Vector3Lerp(consCurrToe, toeTarget, hardFactor);
}
else
{
// Find the target toe locations relative to each other and remove
// any ground penetration
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);
// Softly move toward their targets to bias it toward the source animation
leftToeLocations[i - 1] = Vector3Lerp(consPrevToe, prevToeTarget, softFactor);
leftToeLocations[i - 0] = Vector3Lerp(consCurrToe, currToeTarget, softFactor);
}
// Softly move the pelvis toward the correct location from the source animation
pelvisLocations[i - 1] = Vector3Lerp(consPrevHip, Vector3Add(
consCurrHip, Vector3Subtract(restPrevHip, restCurrHip)), softFactor);
pelvisLocations[i - 0] = Vector3Lerp(consCurrHip, Vector3Add(
consPrevHip, Vector3Subtract(restCurrHip, restPrevHip)), softFactor);
}
そして、フレーム内制約を強制できます。
// Left Leg In-Frame Constraints
// Find the distance from the hip to the toe in the source animation
Vector3 restHip = animation.framePoses[i][pelvisBoneIndex].translation;
Vector3 restToe = animation.framePoses[i][leftToeBoneIndex].translation;
float restLength = Vector3Distance(restHip, restToe);
// Find the current direction from the hip to the toe
Vector3 currHip = pelvisLocations[i];
Vector3 currToe = leftToeLocations[i];
Vector3 currDirection = Vector3Normalize(Vector3Subtract(currHip, currToe));
// Softly enforce the hip-to-toe length from the source animation
pelvisLocations[i] = Vector3Lerp(currHip, Vector3Add(
currToe, Vector3Scale(currDirection, +restLength)), softFactor);
leftToeLocations[i] = Vector3Lerp(currToe, Vector3Add(
currHip, Vector3Scale(currDirection, -restLength)), softFactor);
右足の制約もループ内で全く同じように強制します。完了したら、新しい骨盤とつま先のターゲットを脚ソルバーに渡して、割り当てたメモリを解放できます。
Transform* localTransforms = RL_CALLOC(animation.boneCount, sizeof(Transform));
Transform* globalTransforms = RL_CALLOC(animation.boneCount, sizeof(Transform));
for (int i = 0; i < animation.frameCount; i++)
{
BackwardKinematics(localTransforms, animation.framePoses[i], model);
assert(pelvisBoneIndex == 0);
localTransforms[pelvisBoneIndex].translation = pelvisLocations[i];
ForwardKinematics(globalTransforms, localTransforms, model);
Vector3 leftTargetHeel, leftTargetToe, leftTargetToeEnd;
SolveLegChain(
model,
localTransforms,
globalTransforms,
leftToeLocations[i],
&leftTargetHeel,
&leftTargetToe,
&leftTargetToeEnd,
pelvisBoneIndex,
leftHipBoneIndex,
leftKneeBoneIndex,
leftHeelBoneIndex,
leftToeBoneIndex,
leftToeEndBoneIndex,
enableHeightClamp,
enableHeelLookAt,
enableToeLookAt,
heelMinHeight,
toeMinHeight,
toeEndMinHeight,
softening,
leftKneeSideVector);
Vector3 rightTargetHeel, rightTargetToe, rightTargetToeEnd;
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);
}
RL_FREE(localTransforms);
RL_FREE(globalTransforms);
RL_FREE(pelvisLocations);
RL_FREE(leftToeLocations);
RL_FREE(rightToeLocations);
softFactor と hardFactor を調整することで、フットスライディング制約をどれだけ強く強制するかと、ソースアニメーションをどれだけ追従するかのバランスを調整できます。反復処理を増やすほど良くなりますが、これを高く設定しすぎると完了に時間がかかる可能性があります。
ここでは、フットスライディングを導入するためにルートモーションを1.25倍にスケールアップしたテストを示します。まず、調整なしの場合です。
そして、ランタイムのイネルシャライゼーションベースの除去の場合です。
最後に、オフラインメソッドが生成するものです。
そして、もう1つの比較です。今回はルートモーションを0.75倍にスケールダウンします。まず、変更なしの場合です。
次に、イネルシャライゼーションを使用した場合です。
そして、オフラインメソッドを使用した場合です。
このメソッドはアニメーション全体にアクセスできるため、修正を分散させ、入力アニメーションを維持するのに役立ちます。単に発生する接触に反応するのではなく、うまく機能します。
結論
最後のパートでは、フットスライディングが実際に何であるかをもう少し深く掘り下げ、アイデアがどこから来たのかについてのヒント、トリック、そして哲学を共有したいと思います。
まず、フットスライディングはどこから来るのでしょうか?フットスライディングは、少なくとも2つの異なる状況によって引き起こされます。
- キャラクターのルートが、残りのキャラクターで再生されているアニメーションデータと一致しない方法で移動する場合(私が以前に広範囲に書いたことこちら)。実際、この場合、スライディングしているのは足だけでなく、キャラクター全体です。足は地面に最も近いため、このスライディングに多く気づきますが、実際には足の動きだけでなく、アニメーション全体が間違っている(そして調整されるべき)ことを覚えておくことが重要です。
- スケルトン構造上のローカルジョイント回転を補間、ブレンド、または変更すると、足などのジョイントチェーンの末端で、自然または現実的ではない動きが発生します。
これまでに見てきた例のほとんどは最初のケースであり、ルートモーションの速度をスケーリングしてキャラクターを不適切な速度で移動させることで「スライディング」が発生しましたが、2番目のケースも同様に一般的です。
これら両方のケースで、ランタイムでのアニメーションデータの何らかの変更により、ソースアニメーションデータ内の足の速度が、ゲームで視覚的に見える足の速度と一致しなくなります。これは、特に接触フェーズ中に顕著なアーティファクトです。
そして、これが私の最初の哲学的なポイントと、フットスライディングに関してよくある間違いにつながります。
フットスライディングは速度に関するものです
前述のように、多くの人がよくやる間違いは、足の高さを調べたり、アニメーションデータで地面にどれだけ近いかを調べたりして、足の接触を自動的にラベル付けしようとすることです。彼らはフットスライディングを地面の物理学と摩擦力の違反と考えています。
これが機能しない理由は、フットスライディングの重要なポイントを見逃しているからです。つまり、最終的にランタイムでの足の速度がソースデータでの足の速度と一致するようにしたいということです。接触時間中のみこれを試みるのは、実際にはより実用的な措置です。すべての時間で速度を制約しようとすると、アニメーションの残りの部分に追いつけなくなります。
ルートモーションをスケーリングアップするようなことをすると、足は世界でより速く移動します。そして、この速度誤差は、地面と比較してどれだけ移動しているかを見ると明らかになります。実際、足が接触しているかどうかにかかわらず、速度が大きいほど明らかになります。したがって、実際には、フットスライディングは速度のエラーを修正しており、最も明白な時点(足が接触しているとき)でそれをやろうとしていますが、これは飛行フェーズの足がまだ正しいことを意味するわけではありません!
摩擦と物理学の観点から物事を考えることも、私が2番目の重要なポイントにつながる間違いです。
ロックする必要があるのはつま先です
移動データを調べると、ほとんどの場合、床に接触しているのはつま先ジョイントに接続されている足の部分であることがわかります。運動中の人々は、私たちが予想するよりもはるかに前足で着地する傾向があるため、かかとが床に接触しているが、つま先はそうでない時間はごくわずかです。
さらに、かかとがつまめずに接触する場合、それは安定したポーズであることはめったにありません。一方、つま先がつま先で接触しているが、かかとがそうでないのは信じられないほど一般的であり、つま先接触を中心にピボットしたり、かかとを持ち上げたり下げたりするのをよく見かけます。
[画像: setup]
かかとがつま先なしで床にめり込んでいる悪いアニメーションがあり、「ロック」したくなるかもしれませんが、実際にはそうすべきではありません。かかとを動かす自由度を高くするほど、より見栄えの良い結果が得られます。
したがって、IK問題を解決することを考えると、かかとをロックすることは基本的に忘れ、ほとんどすべてをつま先を制約することに集中すべきです。
これにより、3番目の重要なポイントにつながります。
逆運動学は変更であり、置換ではありません
人々が2関節逆運動学について考えるとき、しばしば、手続き的なルールと(例えば)ポールベクトルに基づいて3つのジョイントのポーズを置き換えるプロセスを考えます。
[画像: setup]
そして、これは通常、リギングを行う際のセットアップ方法です。上記の図で見られるように。
しかし、逆運動学は常にそのように適用される必要はありません。代わりに、ジョイントを所望の位置に移動させますが、回転を可能な限り維持するという、既存のアニメーションへの最小限の変更として適用できます。そして、これが私のフットロッキングのレシピで機能する方法です。なぜなら、かかととつま先の関係、微妙なねじれなど、既存のアニメーションのニュアンスを可能な限り維持したいからです。
そして、私がそれほど触れなかった、心に留めておくべきもう1つの重要なことがあります。
恐竜を避ける
フットロッキングの過剰適用でよく見られる最後の問題は、脚にもっと伸長スペースを与え、過度の伸長を避けるためにキャラクターの腰を下げることです。しかし残念ながら、膝のこの曲がりはティラノサウルス効果を生み出します。
実際の С анимация データを調べると、脚が過度の伸長に非常に近いことがよくわかります。これはフットロッキングを非常に難しくしていることの1つです。
[画像: setup]
腰を下げると過度の伸長はなくなりますが、アニメーションは悪く見えます。この場合、足がスライドするのを許容する方がほとんど常に良いと感じています。そのため、私のレシピでは、入力アニメーションにあるものに伸長を制限しています。
したがって、私の最終的なアドバイスは次のとおりです。少しのスライディングは、運動が機械的に正しいという理由だけでソースアニメーションを壊すよりもはるかに優れています。実際、スライディングという言葉で考えるのをやめてください(物理学と摩擦の比喩は間違った道にあなたを導く可能性があるため)、入力モーションの速度維持という観点から考え始めてください。
これが、フットスライディングを独自のアニメーションシステムで解決する方法についての洞察に富み、いくつかのアイデアの種をまいたことを願っています。この記事のすべてのソースコードはこちらで見つけることができます。そして、いつものように、読んでくれてありがとう!