【実装編②】OpenSiv3Dでスキニングとアニメーションさせる方法
はじめに
この記事では、OpenSiv3DとAssimpを使って、FBXモデルのアニメーションを動かす方法を、具体的なコードと共に解説します。
前編の「解説編」では、アニメーションを動かすための流れを紹介しました。
後編の「実践編」では、さらに3回に分けて、OpenSiv3Dで実装する方法を紹介します。
前編:【解説編】AssimpでFBXアニメーションを動かすための理論的な手順まとめ
第1回目:【実装編①】OpenSiv3DでFBXモデルを読み込んでアニメーション準備する方法
第2回目(今回):【実装編②】OpenSiv3Dでスキニングとアニメーションさせる方法
第3回目:【実装編③】OpenSiv3Dでスキニング済みメッシュを描画する方法
この記事は実装編の第2回目です。
今回は「Skeleton」クラスを中心に、ボーン階層構造やアニメーションの更新処理について解説します。
なお、このコードは私が考えた物で、OpenSiv3Dを使ってUnityの様なコンポーネント構造を想定したものです。
Assimpを使ってゲームエンジンにFBXモデルを取り込もうとしている方にとって参考になると思います。
記事の中では流れに沿って関数ごとに紹介しますが、コードの全文が見たい方は、記事最後をご覧ください。
前提知識
解説編、実装編①の内容を踏まえて実装していきます。
前回、
前々回の記事を見ていない人は、先に見ておくことをオススメします。
ここで、前々回の記事の内容を簡単におさらいしておきます。
Assimpを使ってFBX形式のモデルをアニメーションする手順
① Assimpを使ってFBXファイルを読み込む
② メッシュとボーンの情報を抽出する
③ アニメーションを更新する
④ スキニング処理(ボーンの変形処理)をする
⑤ メッシュを更新する
このうち前回では、①、③~⑤の処理をFBXModelComponentクラスに記述しました。
Skeletonクラスとは?
このクラスは、FBX形式のボーン階層の構築、アニメーションの再生状態管理、現在のアニメーション時間における各ボーンの変形行列を計算しています。
この結果を最終的に頂点や描画側に渡します。(FBXModelComponentのDraw)
つまり、FBXModelComponentから受け取ったボーン階層構造をもとに、アニメーションを管理し、その時間に対応したポーズを計算して返す、ということです。
前々回の手順でいうと、
③ アニメーションを更新する
④ スキニング処理(ボーンの変形処理)をする
これらを担当します。
また、② メッシュとボーンの情報を抽出する もこのクラスで行ないます。
では、実際のコードの中身を見ていきましょう。
メッシュとボーンの情報を抽出する(LoadBonesFromMesh)
FBXファイルには、「どのボーンが、どの頂点に、どれくらい影響するか」というスキニング情報が含まれています。
この関数は、その情報(aiMesh)をSkinnedVertexに記録し、後のスキニング処理(頂点の変形)で使えるように整理しています。
一言で言うと、「各頂点に対して、どのボーンが何%影響しているか記録している処理」です。
行なっている処理を解説します。
ボーン名をインデックスに変換し、オフセット行列を保存する
まず、各ボーンの名前を整数のIDと対応づけ管理します。(boneMapping)
また、オフセット行列も保存します。
void Skeleton::LoadBonesFromMesh(const aiMesh* mesh, Array<SkinnedVertex>& outVertices)
{
outVertices.resize(mesh->mNumVertices);
for (unsigned int i = 0; i < mesh->mNumBones; i++)
{
std::string boneName = mesh->mBones[i]->mName.C_Str();
// 登録されていないボーンを追加
if (boneMapping.find(boneName) == boneMapping.end())
{
int newIndex = static_cast<int>(boneMapping.size());
boneMapping[boneName] = newIndex;
boneOffsetMatrices.resize(newIndex + 1);
}
// オフセット行列も保存
boneOffsetMatrices[boneMapping[boneName]] = mesh->mBones[i]->mOffsetMatrix;
int boneIndex = boneMapping[boneName];
補足:オフセット行列とは?
各ボーンには、「モデルの初期姿勢(バインドポーズ)での位置・姿勢」が記録されています。
この情報は、aiBone::mOffsetMatrix に行列として保存されています。
この行列は、「このボーンが担当する頂点を、メッシュの空間からボーンのローカル空間に変換する行列」
つまり、バインドポーズの逆変換を表しています。
簡単に言うと、「頂点をボーンの空間に持って行くための行列」です。
こうすることで、ボーンが動いたときに、頂点も一緒に正しく動くようになります。
スキニング処理ではこの行列を使って、ボーンの変形が頂点に与える影響を正しく再現します。
そのため、メッシュの情報があるこのタイミングで取得します。
ボーンのインデックスと重みを記録する
続いて、ボーンが影響する頂点ID(さっき対応させたもの)に対して、そのボーンのインデックスと重みを追加します。
// ボーンが与える影響とそのウェイトを保存する
aiBone* bone = mesh->mBones[i];
for (unsigned int j = 0; j < bone->mNumWeights; j++)
{
const aiVertexWeight& weight = bone->mWeights[j];
int vertexId = weight.mVertexId;
float w = weight.mWeight;
outVertices[vertexId].boneIndices.push_back(boneIndex);
outVertices[vertexId].boneWeight.push_back(w);
}
}
重みの正規化を行なう
最後に、重みの合計が1.0になるように正規化します。
for (auto& vertex : outVertices)
{
float totalWeight = 0.0f;
for (float w : vertex.boneWeight)
{
totalWeight += w;
}
if (totalWeight > 0.0f)
{
for (float& w : vertex.boneWeight)
{
w /= totalWeight;
}
}
}
}
こうして、後のスキニング処理で使いやすいように変換します。
この作業にはメッシュが必要です。
前回のFBXModelComponentでメッシュを生成したタイミング(ConvertToSiv3DMesh関数の中)でこれを呼び出します。
アニメーションを更新する(UpdateAnimation)
このゲームでは、毎フレーム deltaTime(経過時間)を加算していくことで、アニメーションの再生が進んでいきます。
この関数では、アニメーションの再生時間(currentTime)を更新しています。
また、あらかじめアニメーションの再生時間を計算しておき、この時間を超えるとcurrentTimeを巻き戻す処理も入れています。
void Skeleton::UpdateAnimation(double deltaTime)
{
if (!scene || scene->mNumAnimations == 0)
{
return;
}
aiAnimation* animation = scene->mAnimations[currentAnimationIndex];
if (!animation)
{
Console << U"Error: scene->mAnimations[currentAnimationIndex] がnullptrです";
return;
}
float durationInSeconds = animation->mDuration / animation->mTicksPerSecond;
currentTime += deltaTime;
// ループ再生
if (currentTime > durationInSeconds)
{
currentTime -= durationInSeconds;
}
CalculateBoneTransform(currentTime);
}
最後のCalculateBoneTransformでは、現在の再生時間に応じたボーンの行列計算をしています。
この計算については次の項目で詳しく解説します。
スキニング処理のための行列を計算する① CalculateBoneTransform関数
ここからは、スキニング処理のための計算をしていきます。
まずは、アニメーションの再生時間の計算です。
ここまでは、アニメーションの再生時間を「秒」で扱ってきました。
しかし、Assimpのアニメーションでは「Tick」という単位で扱っています。
そのため、現在のアニメーション時間を秒からTickに変換します。
void Skeleton::CalculateBoneTransform(float animationTime)
{
currentAnimationIndex = 1;
if (!scene->HasAnimations())
{
Console << U"アニメーションが存在しません";
}
if (currentAnimationIndex < 0 || currentAnimationIndex >= scene->mNumAnimations)
{
Console << U"Error: currentAnimationIndex が範囲外です";
return;
}
aiAnimation* anim = scene->mAnimations[currentAnimationIndex];
double ticksPerSecond = (anim->mTicksPerSecond != 0.0) ? anim->mTicksPerSecond : 25.0;
double timeInTicks = animationTime * ticksPerSecond;
double animTime = fmod(timeInTicks, anim->mDuration);
ReadNodeHierarchy(animTime, scene->mRootNode, aiMatrix4x4(), anim);
}
その後、RootNodeHierarchyを呼び出し、ルートノードから再帰的に各ボーンの姿勢を計算していきます。
スキニング処理のための行列を計算する② ReadNodeHierarchy関数
続いて、各ノード(ボーン)を処理しています。
1. ノードの元の変換行列(バインドポーズ)を取得
void Skeleton::ReadNodeHierarchy(float animationTime, const aiNode* node, const aiMatrix4x4& parentTransform, aiAnimation* animation)
{
std::string nodeName(node->mName.C_Str());
// nodeTransform: デフォルトのボーンの姿勢(BindPose)
aiMatrix4x4 nodeTransform = node->mTransformation;
2. 位置・回転・スケールを補間して行列を作成
アニメーションがある場合は、前後のキーフレームから補間して行列を作ります。
AnimationHelperというのは、位置・回転・スケールが実際に補間の計算をしているところです。
関数の中身は記事の最後でまとめて紹介するのでご覧ください。
// アニメーションチャンネルがある場合は補間する、ない場合はノードのデフォルトの変換を使う
// チャンネルを探す(アニメーション対象)を探す
aiNodeAnim* channel = FindNodeAnimation(animation, nodeName);
if (channel)
{
// 位置・回転・スケールを補完する
Vec3 position = AnimationHelper::InterpolatePosition(channel, animationTime);
aiQuaternion rotation = AnimationHelper::InterpolateRotation(channel, animationTime);
Vec3 scale = AnimationHelper::InterpolateScale(channel, animationTime);
// 補完した位置・回転・スケールを aiMatrix に変換
aiMatrix4x4 translationMatrix, scalingMatrix;
translationMatrix.Translation(aiVector3D(position.x, position.y, position.z), translationMatrix);
// 回転: Quaternion -> Mat4x4 -> aiMatrix4x4
aiMatrix4x4 rotationMatrix(rotation.GetMatrix());
scalingMatrix.Scaling(aiVector3D(scale.x, scale.y, scale.z), scalingMatrix);
nodeTransform = translationMatrix * rotationMatrix * scalingMatrix;
}
3. 現在のグローバル変換行列を作る
さきほど補間して求めた行列に、親のグローバル行列をかけます。
このノードがボーンだった場合は、そのアニメーション時間におけるポーズの行列なので、スキニング用の最終行列(finalBoneTransform)として計算し、格納します。
ここで再帰処理を使っているのは、ボーンが親子構造になっているからです。
親が移動・回転したら、その影響を子も受けます。
そのため、ルートノードから親子関係をたどり、行列を掛け合わせていく再帰処理が必要になります。
aiMatrix4x4 globalTransform = parentTransform * nodeTransform;
// このノードがボーンだった場合、finalBoneTransformを更新する
auto it = boneMapping.find(nodeName);
if (it != boneMapping.end())
{
int boneIndex = it->second;
finalBoneTransform[boneIndex] = globalInverseTransform * globalTransform * boneOffsetMatrices[boneIndex];
}
// 子ノードに再帰
for (unsigned int i = 0; i < node->mNumChildren; i++)
{
ReadNodeHierarchy(animationTime, node->mChildren[i], globalTransform, animation);
}
}
最終変換行列(finalBoneTransform)は、なぜこの順番で掛け合わせるのでしょうか。
行列を一つずつ分解してみます。
boneOffsetMatrix: メッシュの空間 → ボーンのローカル空間に変換する行列
globalTransform: ボーンのローカル空間→現在のワールド空間に変換する行列
globalInverseTransform: ワールド空間→モデルのローカル空間に戻す行列
つまり、まとめると、
頂点をボーン空間に移し(offset)→ ボーンの現在の姿勢を適用(globalTransform)→ ローカル空間に戻す(globalInverse)
この処理を計算式で表すと、
final = globalInverse * currentGlobal * offset
一見すると、「元に戻す→アニメーション姿勢→ボーン空間」の順に見えて直感に反するかも知れません。
行列の掛け算は、右から左へ順番に適用されるので、
この順番は「頂点をボーン空間に移す(Offset)」→「アニメーションで動かす」→「モデル全体の空間に戻す」という意味で正しい順番です。
簡単に言うと、「ボーンをアニメーションで動かした結果を、頂点座標に適用できるように、元のローカル空間に戻す」ための行列計算です。
デバッグや静止ポーズ表示のための関数
このSkeletonクラスには、開発に役立つ補助的な関数もいくつか実装しています。
アニメーションの確認や調整時に便利です。
これらは、あれば便利な機能です。
必要に応じて使ってください。
特定のキーフレームのポーズを表示する
これらの関数は、さきほど説明したアニメーション再生のものとほぼ同じ関数です。
違うのは、「指定されたキーフレームの姿勢をそのまま表示する」ために使います。
エディターなどでこのキーフレーム姿勢を確認したいときに使えます。
void CalculateBoneTransform_StaticPose(int keyFrameIndex);
void ReadNodeHierarchy_StaticPose(const aiNode* node, const aiMatrix4x4& parentTransform, aiAnimation* animation, int keyFrameIndex);
ボーンの階層構造を表示する(DisplayNodeHierarchy)
この関数は、FBXのボーン階層構造をコンソールに表示して可視化するためのものです。
シーン全体の構造を確認したいときなどに使えます。
void Skeleton::DisplayNodeHierarchy(const aiNode* node, int depth)
{
std::string indent(depth * 2, ' ');
Console << Unicode::FromUTF8(indent + node->mName.C_Str());
for (unsigned int i = 0; i < node->mNumChildren; i++)
{
DisplayNodeHierarchy(node->mChildren[i], depth + 1);
}
}
アニメーション再生時間を確認する(DisplayAnimationTime)
現在のアニメーションの再生時間を表示します。
void Skeleton::DisplayAnimationTime() const
{
int currentAnimationIndex = GetCurrentAnimationIndex();
aiAnimation* animation = scene->mAnimations[currentAnimationIndex];
// アニメーション名
String animationName;
if (animation->mName.length > 0)
{
animationName = Unicode::FromUTF8(animation->mName.C_Str());
}
else
{
U"Animation_" + Format(currentAnimationIndex);
}
// アニメーションの全体時間
float duration = animation->mDuration / animation->mTicksPerSecond;
Console << U"再生中のアニメーション: " << animationName;
Console << U"現在の経過時間: " << currentTime << U"秒 / " << duration << U"秒";
}
コード全文
今回紹介したコードを含んだ全てのコードは、Gistにおいてあります。
参考にしてください。
GitHub Gist
おわりに
今回の記事では、Skeletonクラスを中心に、
・メッシュとボーンの抽出
・アニメーションの更新
・スキニング処理の計算
を行ないました。
次回の第3回では、MeshWrapperクラスを紹介します。
これが最後のクラスです。
今回のコードは、行列の計算を中心に数学的な内容が多くなりました。
行列計算を理解するのはかなり難しいですが、一度に全てを理解する必要は無いです。
私も最初は何一つ分かりませんでしたが、徐々に分かるようになりました。
学んで、コードにして、それを記事にする、というサイクルを繰り返す内に、知識がついていくのを実感します。
この記事が少しでもヒントになれば嬉しいです。