ozz-animation で被弾モーションを 4 方向ブレンドする
はじめに
スケルタルアニメーションのランタイムに ozz-animation を使っています。
今回のテーマは 方向つきモーションのブレンドです。敵が攻撃を受けたとき、攻撃が来た方向に応じて「前によろける/後ろにのけぞる/左右に振られる」を切り替えたい。ただし前後左右の4本だけを離散的に切り替えると、斜めから殴られたときに不自然になります。
そこで、前後左右4本の被弾モーションを1つの方向ベクトルから求めた重みで合成し、斜め方向を隣接2方向の中間として表現します。Unity の 2D ブレンドスペース(Simple Directional)の4点版のようなものです。ozz 側は ozz::animation::BlendingJob がそのまま使えるので、実装は思ったより小さく収まりました。
全体の流れ
ozz のポーズ計算は「サンプル → ブレンド → モデル空間化」のジョブを毎フレーム流すだけです。方向ブレンドを入れても、この流れにレイヤーが増えるだけで構造は変わりません。
- 4 本のアニメを 同じ正規化時刻(ratio)でそれぞれ
SamplingJobでサンプル - 方向ベクトルから求めた重みを付けて
BlendingJobで 1 つのローカルポーズに合成 -
LocalToModelJobでモデル空間の行列列へ変換
方向 → 重みの計算
キャラのローカル空間(x=右+、z=前+)の方向ベクトルを、front / back / left / right の 4 重みに変換します。やることは正規化した成分をそのまま各軸の重みにするだけです。
// 方向 → 重み (front back left right の順)
static void WeightsFor(const Vector2& localDir, float outW[4]) {
Vector2 d = localDir;
const float len = d.Length();
if (len < 1e-4f) {
outW[0] = 1.0f; // ゼロベクトルは前扱い
outW[1] = outW[2] = outW[3] = 0.0f;
return;
}
d /= len;
outW[0] = std::max(0.0f, d.y); // front
outW[1] = std::max(0.0f, -d.y); // back
outW[2] = std::max(0.0f, -d.x); // left
outW[3] = std::max(0.0f, d.x); // right
}
たとえば右斜め前(d = (0.707, 0.707))なら front = 0.707、right = 0.707、残りは 0。相反する方向(front と back)が同時に正になることはないので、常に「隣接 2 方向の合成」になります。重みの合計は 1 になりませんが、正規化はブレンド側でまとめて行うのでここでは気にしません。
BlendingJob で合成する
アニメーションコントローラ側には「複数アニメを重み付きで同時再生するブレンドセット」を追加しました。毎フレームの更新はこうなります。
// 全レイヤーを同じ正規化時刻でサンプルし重み合成して current ポーズにする
float weightSum = 0.0f;
for (const auto& layer : m_blendSet) weightSum += layer->weight;
std::vector<ozz::animation::BlendingJob::Layer> layers;
for (auto& layer : m_blendSet) {
SampleAnim(layer->anim, currentRatio, layer->locals, layer->context);
ozz::animation::BlendingJob::Layer l;
// 合計 0 なら等分にフォールバック (rest pose 落ちを防ぐ)
l.weight = (weightSum > 0.0f)
? layer->weight / weightSum
: 1.0f / static_cast<float>(m_blendSet.size());
l.transform = ozz::make_span(layer->locals);
layers.push_back(l);
}
ozz::animation::BlendingJob blend;
blend.layers = ozz::make_span(layers);
blend.rest_pose = m_skeleton->joint_rest_poses();
blend.output = ozz::make_span(m_localTransforms);
blend.threshold = 0.1f;
blend.Run();
ポイントは 3 つです。
- 各レイヤーに専用の
SamplingJob::Contextとローカルポーズバッファを持たせる。Context はアニメごとのキャッシュなので使い回せません - 全レイヤーを同じ ratio でサンプルする。4 本の被弾モーションは同じ尺で作ってあり、時間管理は先頭レイヤーを代表にしています。尺がバラバラだと足の接地などが合わなくなるので、素材側で揃えるのが楽です
- 重みの合計が 0 のときのフォールバックを入れる。
BlendingJobは合計重みがthresholdを下回ると rest pose(バインドポーズ)へ落ちる仕様なので、一瞬 T ポーズが挟まる事故を防ぎます
使う側
呼び出し側は薄いヘルパにまとめました。攻撃が来たワールド方向を敵のローカル空間へ落として渡すだけです。
// 初期化 (4 方向ぶんのアニメを登録)
m_hitBlend.Init(&m_animController,
{ "HitBack_F", "HitBack_B", "HitBack_L", "HitBack_R" });
// 被弾時: 攻撃が来た方向を敵ローカル空間 (x=右 z=前) へ変換して再生
const Vector3 hitFrom = -knockbackDir; // 敵から見た攻撃者側の方向
const Vector2 localDir(hitFrom.Dot(right), hitFrom.Dot(fwd));
m_hitBlend.Play(localDir, 0.05f, /*isLoop=*/false);
再生開始後も SetDirection() で重みだけ差し替えられるので、移動モーション(Walk の 8 方向ストレイフなど)にもそのまま流用できます。今回は被弾リアクション用に単発再生で使っています。
おわりに
- 方向ブレンド自体は「正規化した方向成分 → レイヤー重み」の数行で実装可能
- 4 点で足りなければ重み関数を差し替えるだけで 8 方向にも拡張できる
環境: C++20 / DirectX 11 / ozz-animation
