【実装編①】OpenSiv3DでFBXモデルを読み込んでアニメーション準備する方法
はじめに
この記事では、OpenSiv3DとAssimpを使って、FBXモデルのアニメーションを動かす方法を、具体的なコードと共に解説します。
前編の「解説編」では、アニメーションを動かすための流れを紹介しました。
後編の「実践編」では、さらに3回に分けて、OpenSiv3Dで実装する方法を紹介します。
前編:【解説編】AssimpでFBXアニメーションを動かすための理論的な手順まとめ
第1回目(今回):【実装編①】OpenSiv3DでFBXモデルを読み込んでアニメーション準備する方法
第2回目:【実装編②】OpenSiv3Dでスキニングとアニメーションさせる方法
第3回目:【実装編③】OpenSiv3Dでスキニング済みメッシュを描画する方法
この記事は実装編の第1回目です。
今回は「FBXModelComponent」クラスを中心に、FBXファイルの読み込みとアニメーション準備の処理を解説します。
2回目以降でSkeletonクラスによるアニメーション更新処理や、スキニングの仕組みについても順を追って解説していく予定です。
なお、このコードはOpenSiv3Dを使ってUnityの様なコンポーネント構造を想定したものです。
Assimpを使ってゲームエンジンにFBXモデルを取り込もうとしている方にとって参考になると思います。
記事の中では流れに沿って関数ごとに紹介しますが、コードの全文が見たい方は、記事最後もしくはこちらからご覧ください。
前提知識
「解説編」として紹介した流れを元に実装していきます。
前回の記事を見ていない人は、先に見ておくことをオススメします。
ここで、前回の記事の内容を簡単におさらいしておきます。
Assimpを使ってFBX形式のモデルをアニメーションする手順
① Assimpを使ってFBXファイルを読み込む
② メッシュとボーンの情報を抽出する
③ アニメーションを更新する
④ スキニング処理(ボーンの変形処理)をする
⑤ メッシュを更新する
FBXModelComponentとは?
このクラスは、FBXファイルを読み込んで、モデルとアニメーションを扱うコンポーネントです。
UnityのようなGameObjectに追加されて、描画・更新を担当します。
このクラスを中核として、Skeletonクラス・MeshWrapperクラスをつなぎます。
Skeletonクラス:ボーン階層やアニメーションの更新を担当
MeshWrapperクラス:OpenSiv3Dの描画用メッシュとしての管理・更新を担当
では、前回の手順を元に、実際のコードの中身を見ていきましょう。
コンストラクタ
① Assimpを使ってFBXファイルを読み込む
まずはコンストラクタで、Assimpを使ってFBXファイルを読み込みます。
この処理は前回紹介した手順の「① Assimpを使ってFBXファイルを読み込む」に対応しています。
FBXModelComponent::FBXModelComponent(const FilePath& fbxFilePath, const Optional<FilePath>& texFilePath)
{
// ※!!!!注意!!!!!
// importerを破棄するとsceneも一緒に破棄されます
// importerの破棄によって様々なところでクラッシュするので取り扱いには注意すること
// FBX読み込み
// アニメーションの有無でフラグが変わるので、一旦tempSceneを読み込んでアニメーションの有無を確認する
// その後、再読み込みをする
const aiScene* tempScene = importer.ReadFile(fbxFilePath.narrow(),
aiProcess_Triangulate
| aiProcess_JoinIdenticalVertices
| aiProcess_LimitBoneWeights
| aiProcess_ImproveCacheLocality
| aiProcess_FlipUVs
| aiProcess_ConvertToLeftHanded
);
if (!tempScene)
{
throw std::runtime_error("FBXファイルのロードに失敗しました");
}
// アニメーション:有 -> tempSceneと同じフラグ
if (tempScene->HasAnimations())
{
scene = importer.ReadFile(fbxFilePath.narrow(),
aiProcess_Triangulate
| aiProcess_JoinIdenticalVertices
| aiProcess_LimitBoneWeights
| aiProcess_ImproveCacheLocality
| aiProcess_FlipUVs
| aiProcess_ConvertToLeftHanded
);
}
// アニメーション:無 -> aiProcess_PreTransformVerticesを追加
else
{
scene = importer.ReadFile(fbxFilePath.narrow(),
aiProcess_Triangulate
| aiProcess_JoinIdenticalVertices
| aiProcess_LimitBoneWeights
| aiProcess_ImproveCacheLocality
| aiProcess_FlipUVs
| aiProcess_ConvertToLeftHanded
| aiProcess_PreTransformVertices
);
}
if (!scene || scene->mFlags & AI_SCENE_FLAGS_INCOMPLETE)
{
Console << U"Assimp読み込みエラー:" << Unicode::FromUTF8(importer.GetErrorString());
return;
}
コードとしてはこのようになります。
読み込みを2回に分けているのは、アニメーションがある場合とない場合によってフラグを切り替えたいからです。
このフラグ切り替えに関しては、こちらの記事で詳しく解説しているので合わせてご覧ください。
Assimpのモデルが回転する?PreTransformVerticesフラグとアニメーションの関係
また、読み込みの際に「importer.ReadFile」を使っていますが、importerを破棄するとsceneも一緒に破棄されます!
なので、メンバ変数として持たせるなど、破棄されないようにしましょう。
importerが破棄されると全然関係ないところで謎のエラーが出ることもあります。
こちらの記事で詳しく解説しているので、あわせてご覧ください
Assimpを使ったときの謎のエラーはAssimp::ImporterとaiSceneの寿命のせいかもしれない
メッシュ・ボーンの初期化処理
コンストラクタの続きです。
アニメーションの有無を判定し、アニメーションがある場合のみSkeletonクラスを初期化します。
また、メッシュ関連のクラスMeshWrapperクラスも生成します。
このクラスの中身は第2回、第3回で詳しく説明します。
// Meshの生成
if (scene->mNumMeshes > 0 && scene->mMeshes[0])
{
if (scene->HasAnimations())
{
skeleton = std::make_unique<Skeleton>();
skeleton->Initialize(scene);
}
MeshData data = ConvertToSiv3DMesh(scene->mMeshes[0]);
meshWrapper = std::make_unique<MeshWrapper>(data.vertices, data.indices);
}
else
{
Console << U"メッシュが存在しません";
}
ConvertToSiv3DMesh関数について
この関数では、Assimpのメッシュ(aiMesh)をOpenSiv3D用で使える形式に変換し、スキニング処理用の補助データも用意しています。
また、ボーンの情報をSkeletonクラスへ渡す処理(LoadBonesFromMesh関数)もここで行なっています。
ConvertToSiv3DMesh関数全体コードは記事末尾で紹介しています。
テクスチャの読み込み処理
コンストラクタの最後です。
テクスチャファイルが指定されていれば、ここで読み込む処理を行ないます。
// テクスチャの読み込み(必要なら)
if (texFilePath)
{
LoadTexture(*texFilePath);
}
}
③~⑤モデルの更新処理(Update)
Update関数では、Skeltonのアニメーションを更新し、スキニング(ボーンの変形処理)されたメッシュを反映する処理を担当します。
この処理は前回紹介した手順の「③アニメーションを更新する~⑤メッシュを更新する」を毎フレーム実行するための部分です。
void FBXModelComponent::Update(double deltaTime)
{
if (!isPlaying || !scene->HasAnimations())
{
return;
}
// アニメーションの更新
skeleton->UpdateAnimation(deltaTime);
// スケルトンのボーン変形に従ってスキニングをする
ApplySkinning();
UpdateMeshData();
}
処理の流れとしては、
skeleton->UpdateAnimation(): ボーンの行列を更新
ApplySkinning(): 各頂点にスキニングを適用
UpdateMeshData(): 結果をメッシュに反映
という順で実行されています。
④スキニング処理(ApplySkinning)
この関数では、各頂点が持っているボーンのインデックス・重みに従って、位置と法線を再計算しています。
Assimpから取得した最終的なボーン行列(GetFinalBoneTransform())を使ってスキニングを行なっています。
簡単にいうと、次のアニメーションのポーズに適した各ボーンの行列を取得し(GetFinalBoneTransform())、それを適用させているだけです。
この処理は前回紹介した手順の「④ スキニング処理(ボーンの変形処理)をする」に対応しています。
void FBXModelComponent::ApplySkinning()
{
for (size_t i = 0; i < skinnedVertices.size(); i++)
{
SkinnedVertex& vertex = skinnedVertices[i];
// ウェイトがないときは、元の位置をそのまま使う
if (vertex.boneWeight.empty())
{
vertex.pos = vertex.originalPos;
vertex.normal = vertex.normal;
}
else
{
Vec3 skinnedPos = Vec3(0, 0, 0);
Vec3 skinnedNormal = Vec3(0, 0, 0);
const auto& boneMatrices = skeleton->GetFinalBoneTransform();
for (size_t j = 0; j < vertex.boneIndices.size(); j++)
{
int boneIndex = vertex.boneIndices[j];
float weight = vertex.boneWeight[j];
const aiMatrix4x4 boneMatrix = boneMatrices[boneIndex];
// 位置にスキニング適用
aiVector3D transformed = boneMatrix * aiVector3D(vertex.originalPos.x, vertex.originalPos.y, vertex.originalPos.z);
skinnedPos += Vec3(transformed.x, transformed.y, transformed.z) * weight;
// 法線にスキニング適用(回転・スケーリング部分のみ)
aiMatrix3x3 normalMatrix = aiMatrix3x3(boneMatrix);
aiVector3D transformedNormal = normalMatrix * aiVector3D(vertex.normal.x, vertex.normal.y, vertex.normal.z);
skinnedNormal += Vec3(transformedNormal.x, transformedNormal.y, transformedNormal.z) * weight;
}
vertex.pos = skinnedPos;
vertex.normal = skinnedNormal.normalized();
}
}
}
⑤メッシュの更新処理(UpdateMeshData)
スキニング後の頂点配列を、OpenSiv3DのMesh描画用クラス(MeshWrapper)に渡す処理です。
この関数が実行されることで、画面上のモデルが「動いて」いるように見えます。
この処理は前回紹介した手順の「⑤ メッシュを更新する」に対応しています。
void FBXModelComponent::UpdateMeshData()
{
std::vector<Vertex3D> skinnedMeshVertices;
skinnedMeshVertices.reserve(skinnedVertices.size());
for (const auto& vertex : skinnedVertices)
{
Vertex3D skinnedVertex;
skinnedVertex.pos = vertex.pos;
skinnedVertex.normal = vertex.normal;
skinnedVertex.tex = vertex.tex;
skinnedMeshVertices.push_back(skinnedVertex);
}
if (meshWrapper)
{
meshWrapper->UpdateMeshData(skinnedMeshVertices, indices);
}
}
描画処理(Draw)
最後に、Draw関数では、更新されたメッシュとテクスチャを使ってモデルを描画しています。
owner(コンポーネント構造のGameObject)にアタッチされている情報を使って、正しい位置・回転で描画しています。
この処理は前回紹介した手順に含まれていませんが、最後に「画面に表示する」工程です。
void FBXModelComponent::Draw() const
{
if (!meshWrapper)
{
return;
}
if (auto obj = owner.lock())
{
meshWrapper->Draw(obj->GetTransform(), texture);
}
}
コード全文
今回紹介したコードを含んだ全てのコードは、Gistにおいてあります。
参考にしてください。
GitHub Gist
おわりに
今回の記事では、FBXModelComponentクラスを中心に、
・Assimpを使ったFBXファイルの読み込み
・アニメーション準備(Skeleton, MeshWrapperクラスの初期化)
・メッシュ、テクスチャの読み込み
を行ないました。
また、毎フレームのアニメーション更新と描画処理の流れを実際のコードと共に紹介しました。
一方で、Skeleton, MeshWrapperクラスの内部処理などについては触れていません。
こちらは第2回以降で詳しく紹介します。
今回のコードは、OpenSiv3DのようなライブラリでFBXアニメーションが動くようになる一つの例です。
実装は少し大変ですが、自分で仕組みを作ったときの達成感はひとしおです。
私自身まだまだ勉強中で、無駄なところや不必要な処理があるかもしれません。
少しでもヒントになれば嬉しいです。