前回
アニメイション
スキニングアニメーション
スキニングアニメーションは、キャラクターの動きを定義する骨で構成されたスケルトンを使用し、メッシュにボーンを関連付けて動かす方法です。
おそらく、現存するゲームで最も多く見られるアニメーション方法です。
後ほどコードで見ることになりますが、ボーンが存在し、そのボーンに頂点が接続されることになります。
接続された頂点は、ウェイト値によってどのぐらい影響を受けるかが決定されます。
そして覚えておくべきことは、スケルトンはツリー構造で構成されているということです。
ほとんどのボーンは親ボーンを持っています。
つまり、子ボーンは親ボーンの影響を受けます。
構造
今回はNodeManagerとBoneNodeというクラスを追加する予定です。
BoneNodeはボーン、NodeManagerはすべてのボーンを管理するクラスだと考えてください。NodeManagerはすべてのPMXBoneを受け取り、BoneNodeを適切に生成し、それらを管理します。
まずはBoneNodeの作成から始めましょう。
BoneNode
PMXBoneのデータで初期化し、ボーンが使用するアニメーションのキーフレーム情報も保持する予定です。
アニメーションの計算もここで行い、外部からはボーンの変換行列の結果のみを受け取ります。
class BoneNode
{
public:
BoneNode(unsigned int index, const PMXBone& pmxBone);
unsigned int GetBoneIndex() const { return _boneIndex; }
const std::wstring& GetName() const { return _name; }
unsigned int GetParentBoneIndex() const { return _parentBoneIndex; }
unsigned int GetDeformDepth() const { return _deformDepth; }
unsigned int GetAppendBoneIndex() const { return _appendBoneIndex; }
unsigned int GetIKTargetBoneIndex() const { return _ikTargetBoneIndex; }
void SetParentBoneNode(BoneNode* parentNode)
{
_parentBoneNode = parentNode;
_parentBoneNode->AddChildBoneNode(this);
}
const BoneNode* GetParentBoneNode() const { return _parentBoneNode; }
void AddChildBoneNode(BoneNode* childNode) { _childrenNodes.push_back(childNode); }
const std::vector<BoneNode*>& GetChildrenNodes() const { return _childrenNodes; }
const XMMATRIX& GetInitInverseTransform() const { return _inverseInitTransform; }
const XMMATRIX& GetLocalTransform() const { return _localTransform; }
const XMMATRIX& GetGlobalTransform() const { return _globalTransform; }
void SetAnimateRotation(const XMMATRIX& rotation) { _animateRotation = rotation; }
const XMMATRIX& GetAnimateRotation() const { return _animateRotation; }
const XMFLOAT3& GetAnimatePosition() const { return _animatePosition; }
void SetPosition(const XMFLOAT3& position) { _position = position; }
const XMFLOAT3& GetPosition() const { return _position; }
void SetIKRotation(const XMMATRIX& rotation) { _ikRotation = rotation; }
const XMMATRIX& GetIKRotation() const { return _ikRotation; }
void SetMorphPosition(const XMFLOAT3& position) { _morphPosition = position; }
void SetMorphRotation(const XMMATRIX& rotation) { _morphRotation = rotation; }
void AddMotionKey(unsigned int& frameNo, XMFLOAT4& quaternion, XMFLOAT3& offset, XMFLOAT2& p1, XMFLOAT2& p2);
void SortAllKeys();
void SetEnableAppendRotate(bool enable) { _isAppendRotate = enable; }
void SetEnableAppendTranslate(bool enable) { _isAppendTranslate = enable; }
void SetEnableAppendLocal(bool enable) { _isAppendLocal = enable; }
void SetAppendWeight(float weight) { _appendWeight = weight; }
float GetAppendWeight() const { return _appendWeight; }
void SetAppendBoneNode(BoneNode* node) { _appendBoneNode = node; }
BoneNode* GetAppendBoneNode() const { return _appendBoneNode; }
const XMMATRIX& GetAppendRotation() const { return _appendRotation; }
const XMFLOAT3& GetAppendTranslate() const { return _appendTranslate; }
unsigned int GetMaxFrameNo() const;
void UpdateLocalTransform();
void UpdateGlobalTransform();
void AnimateMotion(unsigned int frameNo);
private:
float GetYFromXOnBezier(float x, const DirectX::XMFLOAT2& a, const DirectX::XMFLOAT2& b, uint8_t n);
private:
unsigned int _boneIndex;
std::wstring _name;
XMFLOAT3 _position;
unsigned int _parentBoneIndex = -1;
unsigned int _deformDepth;
PMXBoneFlags _boneFlag;
unsigned int _appendBoneIndex;
unsigned int _ikTargetBoneIndex;
unsigned int _ikIterationCount;
float _ikLimit;
bool _enableIK = false;
XMFLOAT3 _animatePosition;
XMMATRIX _animateRotation;
XMFLOAT3 _morphPosition;
XMMATRIX _morphRotation;
XMMATRIX _ikRotation;
XMFLOAT3 _appendTranslate;
XMMATRIX _appendRotation;
XMMATRIX _inverseInitTransform;
XMMATRIX _localTransform;
XMMATRIX _globalTransform;
BoneNode* _parentBoneNode = nullptr;
std::vector<BoneNode*> _childrenNodes;
bool _isAppendRotate = false;
bool _isAppendTranslate = false;
bool _isAppendLocal = false;
float _appendWeight = 0.f;
BoneNode* _appendBoneNode = nullptr;
std::vector<VMDKey> _motionKeys;
};
まずはクラスのメンバーから作成していきます。
かなり多いですね。ほとんどはPMXBoneのデータをそのまま移して、メソッドはセッター、ゲッターメソッドがインラインで定義されています。
トランスフォームを計算するメソッドは後に話します。
メンバーにはik、appendなどのメンバーもありますが、これらは後で使用することにして、まずは基本的なアニメーションが再生されるように実装していきます。
アニメーションのキーフレームはVMDKey構造体で定義します。
struct VMDKey
{
unsigned int frameNo;
XMVECTOR quaternion;
XMFLOAT3 offset;
XMFLOAT2 p1;
XMFLOAT2 p2;
VMDKey(unsigned int frameNo, XMVECTOR& quaternion, XMFLOAT3& offset, XMFLOAT2& p1, XMFLOAT2& p2) :
frameNo(frameNo),
quaternion(quaternion),
offset(offset),
p1(p1),
p2(p2)
{}
};
フレーム番号、回転、移動、補間のためのパラメータを持っています。
コンストラクタ
コンストラクタでは、ボーンのインデックスとPMXBoneを受け取ってメンバーを初期化します。
BoneNode::BoneNode(unsigned int index, const PMXBone& pmxBone) :
_boneIndex(index),
_name(pmxBone.name),
_position(pmxBone.position),
_parentBoneIndex(pmxBone.parentBoneIndex),
_deformDepth(pmxBone.deformDepth),
_boneFlag(pmxBone.boneFlag),
_appendBoneIndex(pmxBone.appendBoneIndex),
_ikTargetBoneIndex(pmxBone.ikTargetBoneIndex),
_ikIterationCount(pmxBone.ikIterationCount),
_ikLimit(pmxBone.ikLimit),
_animatePosition(XMFLOAT3(0.f, 0.f, 0.f)),
_animateRotation(XMMatrixIdentity()),
_inverseInitTransform(XMMatrixTranslation(-pmxBone.position.x, -pmxBone.position.y, -pmxBone.position.z)),
_localTransform(XMMatrixIdentity()),
_globalTransform(XMMatrixIdentity()),
_appendTranslate(XMFLOAT3(0.f, 0.f, 0.f)),
_appendRotation(XMMatrixIdentity())
{
}
ほとんどの内容はPMXBoneのデータをコピーするか、初期値で初期化するものです。
覚えておくべきは_inverseInitTransformの初期化部分です。
PMXBoneのpositionに相当する移動行列の逆行列で初期化しています。
PMXBoneのpositionはボーンのバインドポーズです。
バインドポーズはボーンの初期位置です。これはすでにPMXモデルに定義されている値です。
_inverseInitTransformはバインドポーズを基準とする空間に変換する行列だと考えてください。
後で頂点スキニングを行う際に使用するので覚えておいてください。
アニメーションキーフレームの設定
void BoneNode::AddMotionKey(unsigned& frameNo, XMFLOAT4& quaternion, XMFLOAT3& offset, XMFLOAT2& p1, XMFLOAT2& p2)
{
_motionKeys.emplace_back(frameNo, XMLoadFloat4(&quaternion), offset, p1, p2);
}
アニメーションのキーフレーム情報を設定するメソッドです。
後で作成するNodeManagerから呼び出されるでしょう。
void BoneNode::SortAllKeys()
{
std::sort(_motionKeys.begin(), _motionKeys.end(),
[](const VMDKey& left, const VMDKey& right)
{
return left.frameNo <= right.frameNo;
});
}
最初に設定したキーフレームが適切に並べ替えられるようにするメソッドです。
NodeManagerでBoneNodeの初期化が完了すると呼び出されます。
アニメーションメソッド
void BoneNode::AnimateMotion(unsigned frameNo)
{
_animateRotation = XMMatrixIdentity();
_animatePosition = XMFLOAT3(0.f, 0.f, 0.f);
if (_motionKeys.size() <= 0)
{
return;
}
auto rit = std::find_if(_motionKeys.rbegin(), _motionKeys.rend(),
[frameNo](const VMDKey& key)
{
return key.frameNo <= frameNo;
});
XMVECTOR animatePosition = XMLoadFloat3(&rit->offset);
auto iterator = rit.base();
if (iterator != _motionKeys.end())
{
float t = static_cast<float>(frameNo - rit->frameNo) / static_cast<float>(iterator->frameNo - rit->frameNo);
t = GetYFromXOnBezier(t, iterator->p1, iterator->p2, 12);
_animateRotation = XMMatrixRotationQuaternion(XMQuaternionSlerp(rit->quaternion, iterator->quaternion, t));
XMStoreFloat3(&_animatePosition, XMVectorLerp(animatePosition, XMLoadFloat3(&iterator->offset), t));
}
else
{
_animateRotation = XMMatrixRotationQuaternion(rit->quaternion);
}
}
これは、どのキーフレームアニメーション情報を使用してボーンを更新するかを決定する最も重要なメソッドです。
まず、どのframeNoを使用するかはNodeManagerで決定され、パラメータとして渡されます。
その部分は後で扱います。
auto rit = std::find_if(_motionKeys.rbegin(), _motionKeys.rend(),
[frameNo](const VMDKey& key)
{
return key.frameNo <= frameNo;
});
リバースイテレーションを使用してframeNoを検索します。
リバースイテレーションを使うことで、指定されたフレームに最も近い前のフレームを探し、その次のフレームとの補間を行います。
リバースイテレーションでbase()メソッドを使用すると、次のフレームの順方向イテレーションを取得できます。
float t = static_cast<float>(frameNo - rit->frameNo) / static_cast<float>(iterator->frameNo - rit->frameNo);
t = GetYFromXOnBezier(t, iterator->p1, iterator->p2, 12);
_animateRotation = XMMatrixRotationQuaternion(XMQuaternionSlerp(rit->quaternion, iterator->quaternion, t));
XMStoreFloat3(&_animatePosition, XMVectorLerp(animatePosition, XMLoadFloat3(&iterator->offset), t));
tは補間のための係数です。
現在のframeNoが前のキーフレーム(rit→frameNo)と次のキーフレーム(iterator→frameNo)の間でどれだけ進行したかを示す値です。
例えば、前のキーフレームが10で、次のキーフレームが20の場合、現在のキーframeNoが15であればtは0.5になります。
この値を使用して補間を行います。
ベジェ法を使用して再度tを求め、この値を使用して回転は球面線形補間を行い、位置は線形補間を行います。
ここでベジェ曲線について少し見てみましょう。
ベジェ曲線
ベジェ曲線はコンピューターグラフィックスで曲線を表現するための方法です。
制御点と呼ばれるものを使用して曲線を作成することができます。
PMXモデルのアニメーションは線形補間のみを使用せず、フレーム間の動きをより自然に見せるためにベジェ曲線を用いた補間を使用します。
ベジェ曲線の作り方を見てみましょう。
1次ベジェ曲線は曲線ではなく直線です。
式で表すとこのようになります。
AからBに向かう直線で、この直線をLと呼びましょう。
$$
L=((1-t) * A)+(t* B)
$$
これは線形補間の公式と同じですね。
1-tは簡単にsと呼ぶことにします。
$$
L=(s * A )+(t*B)
$$
次に2次ベジェ曲線を見てみましょう。
2次ベジェ曲線は、上記の直線にもう1本追加されます。
ここでtを0.25とし、AとBを補間し、BとCを補間してみましょう。
補間された点同士を補間することができます。
そうすると、この点の間でtが0.25である点を打ってみると、このようになりますね。
上記のような方法で、tが0の時から1の時までのすべての場合を点として打つと、このような曲線が作成されます。
ここまでを式に変換してみましょう。
AとBの補間をLとすると
$$
L(t)=(s* A)+(t*B)
$$
BとCの補間をEとすると
$$
E(t)=(s* B)+(t*C)
$$
上記2つの結果の補間をPとすると
$$
P(t)=s(sA+tB) +t(sB+tC)
$$
このようになります。
もう少し詳しく書くとこのようになります。
$$
P(t)=s^2A+2(st)B+(t^2)C
$$
この式で制御点3つで曲線を表現することができます。
私たちは3次ベジェ曲線を使用します。
したがって、制御点が1つ増えて直線は3本になります。
上で2次ベジェ曲線がどのように作られるかを理解されたなら、3次ベジェ曲線の式を導き出すことができるでしょう。
そのため、3次ベジェ曲線の式の導出は別途行いません。
3次ベジェ曲線の式は次のとおりです。
$$
P(t)=s^3A+3(s^2t)B+3(st^2)C+t^3D
$$
それでは、ベジェ補間メソッドのコードを見てみましょう。
float BoneNode::GetYFromXOnBezier(float x, const DirectX::XMFLOAT2& a, const DirectX::XMFLOAT2& b, uint8_t n)
{
if (a.x == a.y && b.x == b.y)
{
return x;
}
float t = x;
const float k0 = 1 + 3 * a.x - 3 * b.x;
const float k1 = 3 * b.x - 6 * a.x;
const float k2 = 3 * a.x;
for (int i = 0; i < n; ++i)
{
auto ft = k0 * t * t * t + k1 * t * t + k2 * t - x;
if (ft <= epsilon && ft >= -epsilon)
{
break;
}
t -= ft / 2;
}
auto r = 1 - t;
return t * t * t + 3 * t * t * r * b.y + 3 * t * r * r * a.y;
}
float t = x;
const float k0 = 1 + 3 * a.x - 3 * b.x;
const float k1 = 3 * b.x - 6 * a.x;
const float k2 = 3 * a.x;
for (int i = 0; i < n; ++i)
{
auto ft = k0 * t * t * t + k1 * t * t + k2 * t - x;
この部分を見ると、3次ベジェ曲線の式をそのまま移したものです。
このメソッドの目的は、始点が0で終点が1のベジェ曲線において、xに対応するy値を見つけることです。
つまり、渡されたパラメータxに対応するベジェ曲線のy値を見つけることであり、
パラメータa、bは始点と終点を除いた中間の制御点です。
もう一度最初から見てみましょう。
if (a.x == a.y && b.x == b.y)
{
return x;
}
制御点として渡されたaとbのx,y値が同じであれば、結局直線であるため、結果は渡されたxと同じになります。
float t = x;
const float k0 = 1 + 3 * a.x - 3 * b.x;
const float k1 = 3 * b.x - 6 * a.x;
const float k2 = 3 * a.x;
この部分は3次ベジェ曲線の式をそのままコードに変換したものです。
始点と終点をP0、P3とし、渡されたaとbをP1、P2として式で書き直すと、このようになります。
$$
x=(1-t)^3\cdot P_{0}+3(1-t)^2 \cdot t \cdot P_1+3(1-t)\cdot t^2 \cdot P_2+t^3 \cdot P_3
$$
P0が0でP3が1なので、次のように変わります。
$$
x=3(1-t)^2 \cdot t \cdot P_1+3(1-t)\cdot t^2 \cdot P_2+t^3
$$
上記の式を整理すると
$$
x=3\cdot P_1 \cdot t+(-6\cdot P_1+3\cdot P_2)t^2+(3\cdot P_1-3\cdot P_2+1)t^3
$$
このようになります。P1をa.x、P2をb.xに置き換えると、コードのようにk0、k1、k2を作成することができます。
for (int i = 0; i < n; ++i)
{
auto ft = k0 * t * t * t + k1 * t * t + k2 * t - x;
if (ft <= epsilon && ft >= -epsilon)
{
break;
}
t -= ft / 2;
}
このコードでは、渡されたn回だけループを回しながら、xに対応するt値を探しています。
ベジェ曲線は非線形方程式であるため、与えられたx値に対応するt値を直接的に求めることが困難です。
非線形性の反対は線形性です。
線形性は文字通り直線に関連する性質です。
線形性を持つ方程式をグラフで表すと直線が描かれます。
ベジェ曲線は文字通り曲線であるため、線形的ではありません。
コードではニュートン・ラプソン法を使用して方程式が成立するtを見つけています。
ニュートン・ラプソン法は非線形方程式の解を反復的に求める方法です。
ニュートン・ラプソン法は次のような式で表すことができます。
$$
x_{new}=x_{old}-\frac{f(x_{old})}{f'(x_{old})}
$$
f(x)は現在の関数であり、f'(x)はその関数の傾きです。
しかし、コードでは簡略化して傾きで割らず、2で割っています。
$$
x_{new}=x_{old}-\frac{f(x_{old})}{2}
$$
コードをもう一度見ると、上の式通りに繰り返しが行われていることがわかります。
無限に繰り返されないように、適当な値をnとして渡して繰り返しているのです。
auto r = 1 - t;
return t * t * t + 3 * t * t * r * b.y + 3 * t * r * r * a.y;
tを求めたら、これを使って最終的にy値を計算して返します。
ベジェ曲線についての説明が長くなってしまいました。
自然な補間されたアニメーションの回転を求められるようになったので、これをボーンに適用しましょう。
ボーンのローカル変換行列の生成
アニメーションの回転と移動が決定したので、これらを使用して変換行列を作成しましょう。
void BoneNode::UpdateLocalTransform()
{
XMMATRIX scale = XMMatrixIdentity();
XMMATRIX rotation = _animateRotation;
XMVECTOR t = XMLoadFloat3(&_animatePosition) + XMLoadFloat3(&_position)
XMMATRIX translate = XMMatrixTranslationFromVector(t);
_localTransform = scale * rotation * translate;
}
親の変換の適用
ここで作成した変換行列は、ボーンを基準とした変換行列です。
しかし、モデルのボーンはツリー構造で構成されているため、親の影響を受けます。
そのため、上で求めたローカル変換行列に親のグローバル変換行列を掛け合わせます。
void BoneNode::UpdateGlobalTransform()
{
if (_parentBoneNode == nullptr)
{
_globalTransform = _localTransform;
}
else
{
_globalTransform = _localTransform * _parentBoneNode->GetGlobalTransform();
}
for (BoneNode* child : _childrenNodes)
{
child->UpdateGlobalTransform();
}
}
親ボーンがない場合は、自身のローカル変換行列をグローバル変換行列として使用し、
親ボーンがある場合は、親ボーンのグローバル変換行列を掛け合わせます。
そして、子ボーンも同様に再帰的にメソッドを呼び出すことで、すべての子ボーンの変換行列が親の影響を受けるように更新されます。
最初に呼び出す際にルートボーンのみを呼び出せば、すべてのボーンが更新されることになります。
NodeManager
それではNodeManagerを作成しましょう。
このクラスでBoneNodeを生成し、管理します。
class NodeManager
{
public:
NodeManager();
void Init(const std::vector<PMXBone>& bones);
void SortKey();
BoneNode* GetBoneNodeByIndex(int index) const;
BoneNode* GetBoneNodeByName(std::wstring& name) const;
const std::vector<BoneNode*>& GetAllNodes() const { return _boneNodeByIdx; }
void UpdateAnimation(unsigned int frameNo);
void Dispose();
private:
std::unordered_map<std::wstring, BoneNode*> _boneNodeByName;
std::vector<BoneNode*> _boneNodeByIdx;
std::vector<BoneNode*> _sortedNodes;
unsigned int _duration = 0;
};
InitではPMXBoneからBoneNodeを生成します。
UpdateAnimationでは現在のframeNoを決定してBoneNodeに渡します。
Disposeは生成したメンバーを解放します。
他のパブリックメソッドはボーンをインデックスや名前で取得するメソッドです。
これらについては特に説明する必要はないでしょう。
初期化
void NodeManager::Init(const std::vector<PMXBone>& bones)
{
_boneNodeByIdx.resize(bones.size());
_sortedNodes.resize(bones.size());
for (int index = 0; index < bones.size(); index++)
{
const PMXBone& currentBoneData = bones[index];
_boneNodeByIdx[index] = new BoneNode(index, currentBoneData);
_boneNodeByName[_boneNodeByIdx[index]->GetName()] = _boneNodeByIdx[index];
_sortedNodes[index] = _boneNodeByIdx[index];
}
for (int index = 0; index < _boneNodeByIdx.size(); index++)
{
//Parent Bone Set
BoneNode* currentBoneNode = _boneNodeByIdx[index];
unsigned int parentBoneIndex = currentBoneNode->GetParentBoneIndex();
if (parentBoneIndex != 65535 && _boneNodeByIdx.size() > parentBoneIndex)
{
currentBoneNode->SetParentBoneNode(_boneNodeByIdx[currentBoneNode->GetParentBoneIndex()]);
}
}
for (int index = 0; index < _boneNodeByIdx.size(); index++)
{
BoneNode* currentBoneNode = _boneNodeByIdx[index];
if (currentBoneNode->GetParentBoneNode() == nullptr)
{
continue;
}
XMVECTOR pos = XMLoadFloat3(&bones[currentBoneNode->GetBoneIndex()].position);
XMVECTOR parentPos = XMLoadFloat3(&bones[currentBoneNode->GetParentBoneIndex()].position);
XMFLOAT3 resultPos;
XMStoreFloat3(&resultPos, pos - parentPos);
currentBoneNode->SetPosition(resultPos);
}
std::stable_sort(_sortedNodes.begin(), _sortedNodes.end(),
[](const BoneNode* left, const BoneNode * right)
{
return left->GetDeformDepth() < right->GetDeformDepth();
});
}
初期化メソッドです。
ちょっとずつ分けて見ていきましょう。
_boneNodeByIdx.resize(bones.size());
_sortedNodes.resize(bones.size());
for (int index = 0; index < bones.size(); index++)
{
const PMXBone& currentBoneData = bones[index];
_boneNodeByIdx[index] = new BoneNode(index, currentBoneData);
_boneNodeByName[_boneNodeByIdx[index]->GetName()] = _boneNodeByIdx[index];
_sortedNodes[index] = _boneNodeByIdx[index];
}
渡されたPMXBoneを順に回りながらBoneNodeを生成しています。
生成されたオブジェクトはインデックスまたは名前で検索できるようにコンテナにポインタを保存します。
_sortedNodesについては後でで説明します。
for (int index = 0; index < _boneNodeByIdx.size(); index++)
{
//Parent Bone Set
BoneNode* currentBoneNode = _boneNodeByIdx[index];
unsigned int parentBoneIndex = currentBoneNode->GetParentBoneIndex();
if (parentBoneIndex != 65535 && _boneNodeByIdx.size() > parentBoneIndex)
{
currentBoneNode->SetParentBoneNode(_boneNodeByIdx[currentBoneNode->GetParentBoneIndex()]);
}
}
生成されたBoneNodeを順に巡りながら親ボーンと接続していきます。
親ボーンのインデックスが分かっているため、インデックスで見つけることができます。
for (int index = 0; index < _boneNodeByIdx.size(); index++)
{
BoneNode* currentBoneNode = _boneNodeByIdx[index];
if (currentBoneNode->GetParentBoneNode() == nullptr)
{
continue;
}
XMVECTOR pos = XMLoadFloat3(&bones[currentBoneNode->GetBoneIndex()].position);
XMVECTOR parentPos = XMLoadFloat3(&bones[currentBoneNode->GetParentBoneIndex()].position);
XMFLOAT3 resultPos;
XMStoreFloat3(&resultPos, pos - parentPos);
currentBoneNode->SetPosition(resultPos);
}
すべてのBoneNodeの位置メンバーを初期化します。
先ほどBoneNodeで位置メンバーは、後のアニメーション計算後にローカル変換行列を生成する際に使用されていましたね。
このローカル変換行列は親ボーンを基準とする変換行列です。
そのため、初期化する位置も親ボーンを基準とする値でなければならないので、ボーンの位置から親ボーンの位置を引いて使用します。
std::stable_sort(_sortedNodes.begin(), _sortedNodes.end(),
[](const BoneNode* left, const BoneNode * right)
{
return left->GetDeformDepth() < right->GetDeformDepth();
});
初期化が完了したボーンをDeformDepthを基準に並べ替えます。
これは後でIKやアペンド回転を適用する際に、計算されるべき順序でボーンを並べておくためです。
子ボーンが親ボーンより先に計算されないように並べ替えるのです。
このDeformDepth値はすでにPMXモデルに定義されている値なので、これを基準に並べ替えれば良いのです。
アニメーションの並べ替え
まだBoneNodeにアニメーションのキーフレームを渡す部分は作成していませんが、先にアニメーションのキーフレームを並べ替えるメソッドも追加しておきます。
void NodeManager::SortKey()
{
for (int index = 0; index < _boneNodeByIdx.size(); index++)
{
BoneNode* currentBoneNode = _boneNodeByIdx[index];
currentBoneNode->SortAllKeys();
_duration = std::max(_duration, currentBoneNode->GetMaxFrameNo());
}
}
BoneNodeのソートメソッドを呼び出し、アニメーションの長さも保存しておきます。
アニメーションの計算
void NodeManager::UpdateAnimation(unsigned int frameNo)
{
for (BoneNode* curNode : _boneNodeByIdx)
{
curNode->AnimateMotion(frameNo);
curNode->UpdateLocalTransform();
}
if (_boneNodeByIdx.size() > 0)
{
_boneNodeByIdx[0]->UpdateGlobalTransform();
}
}
これは現在のframeNoでアニメーション計算を行うメソッドです。
複雑な処理はすべてBoneNodeで行うようにしているため、frameNoを渡すだけで済みます。
まず、すべてのボーンを巡回してアニメーションを計算し、その結果としてローカル変換行列を更新するようにします。
そして、親ボーンの変換行列も適用されるように、ルートボーンである0番目のインデックスのボーンだけUpdateGlobalTransformを呼び出せば、すべてのボーンのグローバル変換行列が計算されるでしょう。
NodeManagerの準備が完了しました。
これからPMXActorでVMDデータからアニメーションキーフレームを設定し、アニメーション計算を実行し、頂点スキニングを行えるように追加していきます。
PMXActorの修正
NodeManager _nodeManager;
PMXActorのメンバーとしてNodeManagerを追加します。
bool result = LoadPMXFile(filePath, _pmxFileData);
if (result == false)
{
return false;
}
_nodeManager.Init(_pmxFileData.bones);
PMXActorのInitializeでPMXファイルを読み込んだ後、NodeManagerを初期化しましょう。
result = LoadVMDFile(L"VMD\\ラビットホール.vmd", _vmdFileData);
if (result == false)
{
return false;
}
InitAnimation(_vmdFileData);
今度はVMDファイルを読み込み、アニメーションのキーフレーム情報をNodeManagerに設定しましょう。
void PMXActor::InitAnimation(VMDFileData& vmdFileData)
{
for (auto& motion : vmdFileData.motions)
{
auto boneNode = _nodeManager.GetBoneNodeByName(motion.boneName);
if (boneNode == nullptr)
{
continue;
}
boneNode->AddMotionKey(motion.frame,
motion.quaternion,
motion.translate,
XMFLOAT2(static_cast<float>(motion.interpolation[3]) / 127.0f, static_cast<float>(motion.interpolation[7]) / 127.0f),
XMFLOAT2(static_cast<float>(motion.interpolation[11]) / 127.0f, static_cast<float>(motion.interpolation[15]) / 127.0f));
}
_nodeManager.SortKey();
}
これはInitAnimationメソッドです。
VMDのアニメーションキーフレーム情報(VMDMotion)にボーンの名前があります。
この名前を使ってNodeManagerからBoneNodeを取得し、キーフレーム情報を追加します。
VMDMotionのinterpolationの特定の値を127で割って制御点として渡しています。
これらの値は各軸の最後の制御点です。
0〜127の値であるため、0〜1に正規化するために127で割っているのです。
アニメーションの更新
現在のフレーム番号を計算し、NodeManagerのUpdateAnimationを呼び出し、頂点スキニングを処理するメソッドを作成しましょう。
void PMXActor::UpdateAnimation()
{
if (_startTime <= 0)
{
_startTime = timeGetTime();
}
DWORD elapsedTime = timeGetTime() - _startTime;
unsigned int frameNo = 30 * (elapsedTime / 1000.0f);
if (frameNo > _duration)
{
_startTime = timeGetTime();
frameNo = 0;
}
_nodeManager.UpdateAnimation(frameNo);
}
timeGetTimeは現在のシステム時間をミリ秒単位で返すWindowsのメソッドです。
mStartTimeメンバー変数が0以下の場合は、まだアニメーションを開始していないということなので、アニメーション更新の開始時間を記録します。
現在の時間から開始時間を引いて、経過時間を計算します。
そして1秒あたり30フレームを基準に、現在のframeNoを計算します。
このframeNoが現在再生すべきアニメーションのframeNoです。
その下では、frameNoがmDurationに達したらアニメーションを再び最初から再生するように処理しました。
計算したframeNoをNodeManagerのUpdateAnimationを呼び出す際にパラメータとして渡してください。
そうすれば、すべてのボーンが現在のフレームのアニメーションで更新されるでしょう。
ボーンのアニメーションが適用されたので、ボーンに合わせて頂点を移動させる必要があります。
これを頂点スキニングと呼びます。
頂点スキニングを処理するメソッドを追加しましょう。
頂点スキニング
void PMXActor::VertexSkinning()
{
for (unsigned int i = 0; i < _pmxFileData.vertices.size(); ++i)
{
const PMXVertex& currentVertexData = _pmxFileData.vertices[i];
XMVECTOR position = XMLoadFloat3(¤tVertexData.position);
switch (currentVertexData.weightType)
{
case PMXVertexWeight::BDEF1:
{
BoneNode* bone0 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[0]);
XMMATRIX m0 = XMMatrixMultiply(bone0->GetInitInverseTransform(), bone0->GetGlobalTransform());
position = XMVector3Transform(position, m0);
break;
}
case PMXVertexWeight::BDEF2:
{
float weight0 = currentVertexData.boneWeights[0];
float weight1 = 1.0f - weight0;
BoneNode* bone0 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[0]);
BoneNode* bone1 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[1]);
XMMATRIX m0 = XMMatrixMultiply(bone0->GetInitInverseTransform(), bone0->GetGlobalTransform());
XMMATRIX m1 = XMMatrixMultiply(bone1->GetInitInverseTransform(), bone1->GetGlobalTransform());
XMMATRIX mat = m0 * weight0 + m1 * weight1;
position = XMVector3Transform(position, mat);
break;
}
case PMXVertexWeight::BDEF4:
{
float weight0 = currentVertexData.boneWeights[0];
float weight1 = currentVertexData.boneWeights[1];
float weight2 = currentVertexData.boneWeights[2];
float weight3 = currentVertexData.boneWeights[3];
BoneNode* bone0 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[0]);
BoneNode* bone1 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[1]);
BoneNode* bone2 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[2]);
BoneNode* bone3 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[3]);
XMMATRIX m0 = XMMatrixMultiply(bone0->GetInitInverseTransform(), bone0->GetGlobalTransform());
XMMATRIX m1 = XMMatrixMultiply(bone1->GetInitInverseTransform(), bone1->GetGlobalTransform());
XMMATRIX m2 = XMMatrixMultiply(bone2->GetInitInverseTransform(), bone2->GetGlobalTransform());
XMMATRIX m3 = XMMatrixMultiply(bone3->GetInitInverseTransform(), bone3->GetGlobalTransform());
XMMATRIX mat = m0 * weight0 + m1 * weight1 + m2 * weight2 + m3 * weight3;
position = XMVector3Transform(position, mat);
break;
}
case PMXVertexWeight::SDEF:
{
float w0 = currentVertexData.boneWeights[0];
float w1 = 1.0f - w0;
XMVECTOR sdefc = XMLoadFloat3(¤tVertexData.sdefC);
XMVECTOR sdefr0 = XMLoadFloat3(¤tVertexData.sdefR0);
XMVECTOR sdefr1 = XMLoadFloat3(¤tVertexData.sdefR1);
XMVECTOR rw = sdefr0 * w0 + sdefr1 * w1;
XMVECTOR r0 = sdefc + sdefr0 - rw;
XMVECTOR r1 = sdefc + sdefr1 - rw;
XMVECTOR cr0 = (sdefc + r0) * 0.5f;
XMVECTOR cr1 = (sdefc + r1) * 0.5f;
BoneNode* bone0 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[0]);
BoneNode* bone1 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[1]);
XMVECTOR q0 = XMQuaternionRotationMatrix(bone0->GetGlobalTransform());
XMVECTOR q1 = XMQuaternionRotationMatrix(bone1->GetGlobalTransform());
XMMATRIX m0 = XMMatrixMultiply(bone0->GetInitInverseTransform(), bone0->GetGlobalTransform());
XMMATRIX m1 = XMMatrixMultiply(bone1->GetInitInverseTransform(), bone1->GetGlobalTransform());
XMMATRIX rotation = XMMatrixRotationQuaternion(XMQuaternionSlerp(q0, q1, w1));
position = XMVector3Transform(position - sdefc, rotation) + XMVector3Transform(cr0, m0) * w0 + XMVector3Transform(cr1, m1) * w1;
XMVECTOR normal = XMLoadFloat3(¤tVertexData.normal);
normal = XMVector3Transform(normal, rotation);
XMStoreFloat3(&_uploadVertices[i].normal, normal);
break;
}
case PMXVertexWeight::QDEF:
{
BoneNode* bone0 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[0]);
XMMATRIX m0 = XMMatrixMultiply(bone0->GetInitInverseTransform(), bone0->GetGlobalTransform());
position = XMVector3Transform(position, m0);
break;
}
default:
break;
}
XMStoreFloat3(&_uploadVertices[i].position, position);
}
}
BDEF4タイプの場合のみを取り上げて見てみましょう。
case PMXVertexWeight::BDEF4:
{
float weight0 = currentVertexData.boneWeights[0];
float weight1 = currentVertexData.boneWeights[1];
float weight2 = currentVertexData.boneWeights[2];
float weight3 = currentVertexData.boneWeights[3];
BoneNode* bone0 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[0]);
BoneNode* bone1 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[1]);
BoneNode* bone2 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[2]);
BoneNode* bone3 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[3]);
XMMATRIX m0 = XMMatrixMultiply(bone0->GetInitInverseTransform(), bone0->GetGlobalTransform());
XMMATRIX m1 = XMMatrixMultiply(bone1->GetInitInverseTransform(), bone1->GetGlobalTransform());
XMMATRIX m2 = XMMatrixMultiply(bone2->GetInitInverseTransform(), bone2->GetGlobalTransform());
XMMATRIX m3 = XMMatrixMultiply(bone3->GetInitInverseTransform(), bone3->GetGlobalTransform());
XMMATRIX mat = m0 * weight0 + m1 * weight1 + m2 * weight2 + m3 * weight3;
position = XMVector3Transform(position, mat);
break;
}
BDEF4は1つの頂点が4つのボーンの影響を受けることを意味します。
各ボーンからどれだけ影響を受けるかを示す数値がウェイト値です。
頂点情報には関連するボーンのインデックスがあります。
インデックスを使ってNodeManagerからボーンを取得します。
取得したボーンの変換行列で頂点を移動させる必要がありますが、ボーンのグローバル変換行列だけを使用してはいけません。
コードを見ると、GetInitInverseTransformで取得した行列とGetGlobalTransformで取得した行列を掛け合わせています。
その理由を考えてみましょう。
再びボーンのバインドポーズというものが登場します。
以前に一度話しましたね。
バインドポーズはボーンの初期位置だと言いました。
頂点はボーンの影響を受けます。
そのため、ボーンを基準に動かさなければ頂点が正しく移動しません。
GetInitInverseTransformで返される行列がどのような行列か覚えていますか?
ボーンのバインドポーズを基準に変換する行列
つまり、ボーンの初期位置に戻す行列です。
ここにボーンのグローバル変換行列を掛けて合成行列を作り、頂点に掛けます。
このプロセスにより、頂点はボーンのバインドポーズから現在のアニメーション状態に正しく変形されます。
最終変換行列を計算したら、ウェイト値を適用して頂点位置に掛けます。
BDEF1、BDEF2の内容も大きく違わず、影響を受けるボーンの数が異なるだけです。
少し異なるものにSDEFとQDEFがあります。
QDEFはデュアルクォータニオンというものを使用しますが、私が使用したモデルにはQDEFタイプを使用する頂点がないため実装していません。
SDEFは原理について説明すると非常に複雑になります。別途原理を説明した記事をリンクしておきますので、気になる方はそちらをご覧ください。
void PMXActor::UpdateAnimation()
{
if (_startTime <= 0)
{
_startTime = timeGetTime();
}
DWORD elapsedTime = timeGetTime() - _startTime;
unsigned int frameNo = 30 * (elapsedTime / 1000.0f);
if (frameNo > _duration)
{
_startTime = timeGetTime();
frameNo = 0;
}
_nodeManager.UpdateAnimation(frameNo);
VertexSkinning();
std::copy(_uploadVertices.begin(), _uploadVertices.end(), _mappedVertex);
}
UpdateAnimationメソッドで作成したVertexSkinningメソッドを呼び出し、スキニングが完了した頂点をマッピングされたメモリにコピーします。
ここまで実装してビルドしてみましょう。
アニメーションがうまく適用されました。
腕や脚がおかしいと思われるかもしれませんが、まだIK、アペンド回転、モーフィングが実装されていないためです。
これらは次の記事で扱うことにします。
おそらく現在の状態では、上の画像よりもはるかにぎこちなく見えるでしょう。
頂点が約4万個を超えており、毎フレーム4万個の頂点スキニングを待ってから描画するため、FPSが低くなっているのです。
頂点スキニングをより速く計算するために、並列処理を試してみましょう。
並列処理
並列処理のために std::future と std::async を使用します。
std::future は非同期タスクの結果を待つことができ、
std::async は新しいスレッドで非同期的に関数を実行することができます。
頂点を分割してスキニングするために構造体を定義します。
struct SkinningRange
{
unsigned int startIndex;
unsigned int vertexCount;
};
内容は大したことはありません。
頂点の開始インデックスと処理する頂点の数を持っています。
PMXActorに以下のようにメンバーを追加します。
std::vector<SkinningRange> _skinningRanges;
std::vector<std::future<void>> _parallelUpdateFutures;
PMXActorの初期化段階で並列処理を初期化するメソッドを追加します。
void PMXActor::InitParallelVertexSkinningSetting()
{
unsigned int threadCount = std::thread::hardware_concurrency();
unsigned int divNum = threadCount - 1;
_skinningRanges.resize(threadCount);
_parallelUpdateFutures.resize(threadCount);
unsigned int divVertexCount = _pmxFileData.vertices.size() / divNum;
unsigned int remainder = _pmxFileData.vertices.size() % divNum;
int startIndex = 0;
for (int i = 0; i < _skinningRanges.size() - 1; i++)
{
_skinningRanges[i].startIndex = startIndex;
_skinningRanges[i].vertexCount = divVertexCount;
startIndex += _skinningRanges[i].vertexCount;
}
_skinningRanges[_skinningRanges.size() - 1].startIndex = startIndex;
_skinningRanges[_skinningRanges.size() - 1].vertexCount = remainder;
}
スレッドの数だけ分割して処理するようにします。
hardware_concurrencyは現在のシステムのスレッド数を返します。
頂点の数がスレッド数で正確に割り切れない可能性があるため、スレッド数から1を引いた数で分割し、残りの頂点は最後のスレッドで計算するようにします。
SkinningRangeで処理を開始する頂点のインデックスと頂点数を分割します。
簡単に記述するためにこのように分割しましたが、このように考えると、もっと良い方法があるのではないかと思います。
より良い分割方法が思い浮かんだら、修正してください。
例えば、全頂点の数をスレッド数で均等に分配することもできるでしょう。
これから以前に作成した頂点スキニングメソッドを再度作成します。
void PMXActor::VertexSkinningByRange(const SkinningRange& range)
{
for (unsigned int i = 0; i < _pmxFileData.vertices.size(); ++i)
{
const PMXVertex& currentVertexData = _pmxFileData.vertices[i];
XMVECTOR position = XMLoadFloat3(¤tVertexData.position);
switch (currentVertexData.weightType)
{
case PMXVertexWeight::BDEF1:
{
BoneNode* bone0 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[0]);
XMMATRIX m0 = XMMatrixMultiply(bone0->GetInitInverseTransform(), bone0->GetGlobalTransform());
position = XMVector3Transform(position, m0);
break;
}
case PMXVertexWeight::BDEF2:
{
float weight0 = currentVertexData.boneWeights[0];
float weight1 = 1.0f - weight0;
BoneNode* bone0 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[0]);
BoneNode* bone1 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[1]);
XMMATRIX m0 = XMMatrixMultiply(bone0->GetInitInverseTransform(), bone0->GetGlobalTransform());
XMMATRIX m1 = XMMatrixMultiply(bone1->GetInitInverseTransform(), bone1->GetGlobalTransform());
XMMATRIX mat = m0 * weight0 + m1 * weight1;
position = XMVector3Transform(position, mat);
break;
}
case PMXVertexWeight::BDEF4:
{
float weight0 = currentVertexData.boneWeights[0];
float weight1 = currentVertexData.boneWeights[1];
float weight2 = currentVertexData.boneWeights[2];
float weight3 = currentVertexData.boneWeights[3];
BoneNode* bone0 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[0]);
BoneNode* bone1 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[1]);
BoneNode* bone2 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[2]);
BoneNode* bone3 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[3]);
XMMATRIX m0 = XMMatrixMultiply(bone0->GetInitInverseTransform(), bone0->GetGlobalTransform());
XMMATRIX m1 = XMMatrixMultiply(bone1->GetInitInverseTransform(), bone1->GetGlobalTransform());
XMMATRIX m2 = XMMatrixMultiply(bone2->GetInitInverseTransform(), bone2->GetGlobalTransform());
XMMATRIX m3 = XMMatrixMultiply(bone3->GetInitInverseTransform(), bone3->GetGlobalTransform());
XMMATRIX mat = m0 * weight0 + m1 * weight1 + m2 * weight2 + m3 * weight3;
position = XMVector3Transform(position, mat);
break;
}
case PMXVertexWeight::SDEF:
{
float w0 = currentVertexData.boneWeights[0];
float w1 = 1.0f - w0;
XMVECTOR sdefc = XMLoadFloat3(¤tVertexData.sdefC);
XMVECTOR sdefr0 = XMLoadFloat3(¤tVertexData.sdefR0);
XMVECTOR sdefr1 = XMLoadFloat3(¤tVertexData.sdefR1);
XMVECTOR rw = sdefr0 * w0 + sdefr1 * w1;
XMVECTOR r0 = sdefc + sdefr0 - rw;
XMVECTOR r1 = sdefc + sdefr1 - rw;
XMVECTOR cr0 = (sdefc + r0) * 0.5f;
XMVECTOR cr1 = (sdefc + r1) * 0.5f;
BoneNode* bone0 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[0]);
BoneNode* bone1 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[1]);
XMVECTOR q0 = XMQuaternionRotationMatrix(bone0->GetGlobalTransform());
XMVECTOR q1 = XMQuaternionRotationMatrix(bone1->GetGlobalTransform());
XMMATRIX m0 = XMMatrixMultiply(bone0->GetInitInverseTransform(), bone0->GetGlobalTransform());
XMMATRIX m1 = XMMatrixMultiply(bone1->GetInitInverseTransform(), bone1->GetGlobalTransform());
XMMATRIX rotation = XMMatrixRotationQuaternion(XMQuaternionSlerp(q0, q1, w1));
position = XMVector3Transform(position - sdefc, rotation) + XMVector3Transform(cr0, m0) * w0 + XMVector3Transform(cr1, m1) * w1;
XMVECTOR normal = XMLoadFloat3(¤tVertexData.normal);
normal = XMVector3Transform(normal, rotation);
XMStoreFloat3(&_uploadVertices[i].normal, normal);
break;
}
case PMXVertexWeight::QDEF:
{
BoneNode* bone0 = _nodeManager.GetBoneNodeByIndex(currentVertexData.boneIndices[0]);
XMMATRIX m0 = XMMatrixMultiply(bone0->GetInitInverseTransform(), bone0->GetGlobalTransform());
position = XMVector3Transform(position, m0);
break;
}
default:
break
}
XMStoreFloat3(&_uploadVertices[i].position, position);
}
}
内容は変わりません。
ただし、SkinningRangeを受け取り、その範囲の頂点のみを計算するようにします。
そして、このメソッドを並列的に実行できるようにしましょう。
void PMXActor::VertexSkinning()
{
const int futureCount = _parallelUpdateFutures.size();
for (int i = 0; i < futureCount; i++)
{
const SkinningRange& currentRange = _skinningRanges[i];
_parallelUpdateFutures[i] = std::async(std::launch::async, [this, currentRange]()
{
this->VertexSkinningByRange(currentRange);
});
}
for (const std::future<void>& future : _parallelUpdateFutures)
{
future.wait();
}
}
std::asyncをstd::launch::asyncフラグで使用すると、その後に続くメソッドが新しいスレッドで実行されます。
そして、返されるものをmParallelUpdateFuturesに保存しておき、
forループですべての非同期タスクが完了するのを待ちます。
wait()は、std::futureオブジェクトが表す非同期タスクが完了するまで待機させます。
このようにすれば、すべてのスレッドの作業が完了するまでこのメソッドは終了しないでしょう。
ここまで行って、毎フレーム更新していた部分をこれに置き換えてください。
これにより、以前よりも明らかにFPSが向上したことが確認できるはずです。
まとめ
ミクさんが踊るようにアニメーションを実装しました。
途中でベジェ曲線の説明をしたため、少し長くなった感じがあります。
まだ手や足が正しく動いていません。
次の記事では全体が正しく動くようにします。
ありがとうございます
参考リンク
https://qiita.com/dragonmeteor/items/0211166d55bb2eb7c07c
https://www.summbit.com/blog/bezier-curve-guide/
https://en.wikipedia.org/wiki/Bézier_curve
https://en.wikipedia.org/wiki/Newton's_method
次回