ことれいのもり

【実装編②】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クラスを紹介します。

これが最後のクラスです。


今回のコードは、行列の計算を中心に数学的な内容が多くなりました。

行列計算を理解するのはかなり難しいですが、一度に全てを理解する必要は無いです。

私も最初は何一つ分かりませんでしたが、徐々に分かるようになりました。

学んで、コードにして、それを記事にする、というサイクルを繰り返す内に、知識がついていくのを実感します。

この記事が少しでもヒントになれば嬉しいです。


記事一覧

前回の記事:【解説編】AssimpでFBXアニメーションを動かすための理論的な手順まとめ

第1回目:【実装編①】OpenSiv3DでFBXモデルを読み込んでアニメーション準備する方法

第2回目(今回):【実装編②】OpenSiv3Dでスキニングとアニメーションさせる方法

第3回目:【実装編③】OpenSiv3Dでスキニング済みメッシュを描画する方法